diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf
--- a/docker/proxy/rootfs/etc/nginx/nginx.conf
+++ b/docker/proxy/rootfs/etc/nginx/nginx.conf
@@ -140,7 +140,9 @@
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         }
 
-        rewrite ^/\\.well-known/(caldav|carddav) https://\$server_name/iRony/ redirect;
+        location ~ ^/\\.well-known/(caldav|carddav)(.*)$ {
+            return 301 /iRony/$2;
+        }
 
         location /iRony {
             auth_request     /auth;
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -26,7 +26,7 @@
      */
     public function __construct($user, $password)
     {
-        $this->url      = \config('dav.uri');
+        $this->url      = \config('services.dav.uri');
         $this->user     = $user;
         $this->password = $password;
     }
diff --git a/src/config/dav.php b/src/config/dav.php
deleted file mode 100644
--- a/src/config/dav.php
+++ /dev/null
@@ -1,5 +0,0 @@
-<?php
-
-return [
-    'uri' => env('DAV_URI', 'http://kolab/dav'),
-];
diff --git a/src/config/services.php b/src/config/services.php
--- a/src/config/services.php
+++ b/src/config/services.php
@@ -54,6 +54,21 @@
 
     'openexchangerates' => [
         'api_key' => env('OPENEXCHANGERATES_API_KEY', null),
-    ]
+    ],
 
+    'dav' => [
+        'uri' => env('DAV_URI', 'https://proxy/'),
+    ],
+
+    'activesync' => [
+        'uri' => env('ACTIVESYNC_URI', 'https://proxy/Microsoft-Server-ActiveSync'),
+    ],
+
+    'wopi' => [
+        'uri' => env('WOPI_URI', 'http://roundcube/chwala/'),
+    ],
+
+    'webmail' => [
+        'uri' => env('WEBMAIL_URI', 'http://roundcube/roundcubemail/'),
+    ]
 ];
diff --git a/src/tests/Infrastructure/ActivesyncTest.php b/src/tests/Infrastructure/ActivesyncTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Infrastructure/ActivesyncTest.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace Tests\Infrastructure;
+
+use Tests\TestCase;
+use Illuminate\Support\Str;
+
+class ActivesyncTest extends TestCase
+{
+    private static ?\GuzzleHttp\Client $client = null;
+    private static ?\App\User $user = null;
+    private static ?string $deviceId = null;
+
+    private static function toWbxml($xml)
+    {
+        $outputStream = fopen("php://temp", 'r+');
+        $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3);
+        $dom = new \DOMDocument();
+        $dom->loadXML($xml);
+        $encoder->encode($dom);
+        rewind($outputStream);
+        return stream_get_contents($outputStream);
+    }
+
+    private static function fromWbxml($binary)
+    {
+        $stream = fopen('php://memory', 'r+');
+        fwrite($stream, $binary);
+        rewind($stream);
+        $decoder = new \Syncroton_Wbxml_Decoder($stream);
+        return $decoder->decode();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (!self::$user) {
+            self::$user = $this->getTestUser('activesynctest@kolab.org', ['password' => 'simple123'], true);
+        }
+        if (!self::$deviceId) {
+            // By always creating a new device we force syncroton to initialize.
+            // Otherwise we work against uninitialized metadata (subscription states),
+            // because the account has been removed, but syncroton doesn't reinitalize the metadata for known devices.
+            self::$deviceId = (string) Str::uuid();
+        }
+        if (!self::$client) {
+            self::$client = new \GuzzleHttp\Client([
+                    'http_errors' => false, // No exceptions
+                    'base_uri' => \config("services.activesync.uri"),
+                    'verify' => false,
+                    'auth' => [self::$user->email, 'simple123'],
+                    'connect_timeout' => 10,
+                    'timeout' => 10,
+                    'headers' => [
+                        "Content-Type" => "application/xml; charset=utf-8",
+                        "Depth" => "1",
+                    ]
+            ]);
+        }
+    }
+
+    public function testOptions()
+    {
+        $response = self::$client->request('OPTIONS', '');
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertStringContainsString('14', $response->getHeader('MS-Server-ActiveSync')[0]);
+        $this->assertStringContainsString('14.1', $response->getHeader('MS-ASProtocolVersions')[0]);
+        $this->assertStringContainsString('FolderSync', $response->getHeader('MS-ASProtocolCommands')[0]);
+    }
+
+    public function testList()
+    {
+        $user = self::$user;
+        $deviceId = self::$deviceId;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>0</SyncKey>
+        </FolderSync>
+        EOF;
+        $body = self::toWbxml($request);
+        $response = self::$client->request(
+            'POST',
+            "?Cmd=FolderSync&User={$user->email}&DeviceId={$deviceId}&DeviceType=iphone",
+            [
+                'headers' => [
+                    "Content-Type" => "application/vnd.ms-sync.wbxml",
+                    'MS-ASProtocolVersion' => "14.0"
+                ],
+                'body' => $body
+            ]
+        );
+        $this->assertEquals(200, $response->getStatusCode());
+        $dom = self::fromWbxml($response->getBody());
+        $xml = $dom->saveXML();
+
+        $this->assertStringContainsString('INBOX', $xml);
+        // The hash is based on the name, so it's always the same
+        $inboxId = '38b950ebd62cd9a66929c89615d0fc04';
+        $this->assertStringContainsString($inboxId, $xml);
+        //TODO for this to work we need to create the default folders in IMAP::createUser
+        // $this->assertStringContainsString('Drafts', $result);
+        // $this->assertStringContainsString('Sent', $result);
+        // $this->assertStringContainsString('Trash', $result);
+        // $this->assertStringContainsString('Calendar', $result);
+        // $this->assertStringContainsString('Contacts', $result);
+
+        // Find the inbox for the next step
+        // $collectionIds = $dom->getElementsByTagName('ServerId');
+        // $inboxId = $collectionIds[0]->nodeValue;
+
+        return $inboxId;
+    }
+
+    /**
+    * @depends testList
+    */
+    public function testInitialSync($inboxId)
+    {
+        $user = self::$user;
+        $deviceId = self::$deviceId;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>{$inboxId}</CollectionId>
+                    <DeletesAsMoves>0</DeletesAsMoves>
+                    <GetChanges>0</GetChanges>
+                    <WindowSize>512</WindowSize>
+                    <Options>
+                        <FilterType>0</FilterType>
+                        <BodyPreference xmlns="uri:AirSyncBase">
+                            <Type>1</Type>
+                            <AllOrNone>1</AllOrNone>
+                        </BodyPreference>
+                    </Options>
+                </Collection>
+            </Collections>
+            <WindowSize>16</WindowSize>
+        </Sync>
+        EOF;
+        $body = self::toWbxml($request);
+        $response = self::$client->request(
+            'POST',
+            "?Cmd=Sync&User={$user->email}&DeviceId={$deviceId}&DeviceType=iphone",
+            [
+                'headers' => [
+                    "Content-Type" => "application/vnd.ms-sync.wbxml",
+                    'MS-ASProtocolVersion' => "14.0"
+                ],
+                'body' => $body
+            ]
+        );
+        $this->assertEquals(200, $response->getStatusCode());
+        $dom = self::fromWbxml($response->getBody());
+        $status = $dom->getElementsByTagName('Status');
+        $this->assertEquals("1", $status[0]->nodeValue);
+        $collections = $dom->getElementsByTagName('Collection');
+        $this->assertEquals(1, $collections->length);
+        $collection = $collections->item(0);
+        $this->assertEquals("Class", $collection->childNodes->item(0)->nodeName);
+        $this->assertEquals("Email", $collection->childNodes->item(0)->nodeValue);
+        $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName);
+        $this->assertEquals("1", $collection->childNodes->item(1)->nodeValue);
+        $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName);
+        $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue);
+    }
+
+    /**
+     * @doesNotPerformAssertions
+     */
+    public function testCleanup(): void
+    {
+        $this->deleteTestUser(self::$user->email);
+    }
+}
diff --git a/src/tests/Infrastructure/AutodiscoverTest.php b/src/tests/Infrastructure/AutodiscoverTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Infrastructure/AutodiscoverTest.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Tests\Infrastructure;
+
+use Tests\TestCase;
+
+class AutodiscoverTest extends TestCase
+{
+    private static ?\GuzzleHttp\Client $client = null;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+        if (!self::$client) {
+            self::$client = new \GuzzleHttp\Client([
+                'http_errors' => false, // No exceptions
+                'base_uri' => "http://roundcube/",
+                'verify' => false,
+                'connect_timeout' => 10,
+                'timeout' => 10,
+            ]);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+    }
+
+    public function testWellKnownOutlook()
+    {
+        $body = <<<EOF
+        <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
+            <Request>
+                <EMailAddress>admin@example.local</EMailAddress>
+                <AcceptableResponseSchema>
+                    http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a
+                </AcceptableResponseSchema>
+            </Request>
+        </Autodiscover>
+        EOF;
+        $response = self::$client->request('POST', 'autodiscover/autodiscover.xml', [
+            'headers' => [
+                "Content-Type" => "text/xml; charset=utf-8"
+            ],
+            'body' => $body
+            ]);
+        $this->assertEquals($response->getStatusCode(), 200);
+        $data = $response->getBody();
+        $this->assertTrue(str_contains($data, '<Server>example.local</Server>'));
+        $this->assertTrue(str_contains($data, 'admin@example.local'));
+    }
+
+    public function testWellKnownActivesync()
+    {
+        $body = <<<EOF
+        <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006">
+            <Request>
+            <EMailAddress>admin@example.local</EMailAddress>
+            <AcceptableResponseSchema>
+                http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006
+                </AcceptableResponseSchema>
+            </Request>
+        </Autodiscover>
+        EOF;
+        $response = self::$client->request('POST', 'autodiscover/autodiscover.xml', [
+            'headers' => [
+                "Content-Type" => "text/xml; charset=utf-8"
+            ],
+            'body' => $body
+            ]);
+        $this->assertEquals($response->getStatusCode(), 200);
+        $data = $response->getBody();
+        $this->assertTrue(str_contains($data, '<Url>https://example.local/Microsoft-Server-ActiveSync</Url>'));
+        $this->assertTrue(str_contains($data, 'admin@example.local'));
+    }
+
+    public function testWellKnownMail()
+    {
+        $response = self::$client->request(
+            'GET',
+            '.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=fred@example.com'
+        );
+        $this->assertEquals($response->getStatusCode(), 200);
+    }
+}
diff --git a/src/tests/Infrastructure/DavTest.php b/src/tests/Infrastructure/DavTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Infrastructure/DavTest.php
@@ -0,0 +1,293 @@
+<?php
+
+namespace Tests\Infrastructure;
+
+use Tests\TestCase;
+
+class DavTest extends TestCase
+{
+    private static ?\GuzzleHttp\Client $client = null;
+    private static ?\App\User $user = null;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (!self::$user) {
+            self::$user = $this->getTestUser('davtest@kolab.org', ['password' => 'simple123'], true);
+        }
+
+        if (!self::$client) {
+            self::$client = new \GuzzleHttp\Client([
+                'http_errors' => false, // No exceptions
+                'base_uri' => \config("services.dav.uri"),
+                'verify' => false,
+                'auth' => [self::$user->email, 'simple123'],
+                'connect_timeout' => 10,
+                'timeout' => 10,
+                'headers' => [
+                    "Content-Type" => "application/xml; charset=utf-8",
+                    "Depth" => "1",
+                ]
+            ]);
+        }
+    }
+
+    public function testDiscoverPrincipal()
+    {
+        $user = self::$user;
+        $body = "<d:propfind xmlns:d='DAV:'><d:prop><d:current-user-principal/></d:prop></d:propfind>";
+        $response = self::$client->request('PROPFIND', '/iRony/', ['body' => $body]);
+        $this->assertEquals(207, $response->getStatusCode());
+        $data = $response->getBody();
+        $this->assertStringContainsString("<d:href>/iRony/principals/{$user->email}/</d:href>", $data);
+        $this->assertStringContainsString('<d:href>/iRony/calendars/</d:href>', $data);
+        $this->assertStringContainsString('<d:href>/iRony/addressbooks/</d:href>', $data);
+    }
+
+    /**
+     * This codepath is triggerd by MacOS CalDAV when it tries to login.
+     * Verify we don't crash and end up with a 500 status code.
+     */
+    public function testFailingLogin()
+    {
+        $body = "<d:propfind xmlns:d='DAV:'><d:prop><d:current-user-principal/></d:prop></d:propfind>";
+        $headers = [
+            "Content-Type" => "application/xml; charset=utf-8",
+            "Depth" => "1",
+            'body' => $body,
+            'auth' => ['invaliduser@kolab.org', 'invalid']
+        ];
+
+        $response = self::$client->request('PROPFIND', '/iRony/', $headers);
+        $this->assertEquals(403, $response->getStatusCode());
+    }
+
+    /**
+     * This codepath is triggerd by MacOS CardDAV when it tries to login.
+     * NOTE: This depends on the username_domain roundcube config option.
+     */
+    public function testShortlogin()
+    {
+        $this->markTestSkipped(
+            'Shortlogins dont work with the nginx proxy.'
+        );
+        $body = "<d:propfind xmlns:d='DAV:'><d:prop><d:current-user-principal/></d:prop></d:propfind>";
+        $response = self::$client->request('PROPFIND', '/iRony/', [
+            'body' => $body,
+            'auth' => ['davtest', 'simple123']
+        ]);
+        $this->assertEquals(207, $response->getStatusCode());
+    }
+
+    public function testDiscoverCalendarHomeset()
+    {
+        $user = self::$user;
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <c:calendar-home-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        $response = self::$client->request('PROPFIND', '/iRony/', ['body' => $body]);
+        $this->assertEquals(207, $response->getStatusCode());
+        $data = $response->getBody();
+        $this->assertStringContainsString("<d:href>/iRony/calendars/{$user->email}/</d:href>", $data);
+    }
+
+    public function testDiscoverCalendars()
+    {
+        $user = self::$user;
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <d:resourcetype />
+                    <d:displayname />
+                    <cs:getctag />
+                    <c:supported-calendar-component-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        $response = self::$client->request('PROPFIND', "/iRony/calendars/{$user->email}", [
+            'headers' => [
+                "Depth" => "infinity",
+            ],
+            'body' => $body
+        ]);
+        $this->assertEquals(207, $response->getStatusCode());
+        $data = $response->getBody();
+        $this->assertStringContainsString("<d:href>/iRony/calendars/{$user->email}/</d:href>", $data);
+
+        $doc = new \DOMDocument('1.0', 'UTF-8');
+        $doc->loadXML($data);
+        $response = $doc->getElementsByTagName('response')->item(1);
+        $doc->getElementsByTagName('href')->item(0);
+
+        $this->assertEquals("d:href", $response->childNodes->item(0)->nodeName);
+        $href = $response->childNodes->item(0)->nodeValue;
+        return $href;
+    }
+
+    /**
+     * @depends testDiscoverCalendars
+     */
+    public function testPropfindCalendar($href)
+    {
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <d:resourcetype />
+                    <d:owner/>
+                    <d:current-user-principal/>
+                    <d:current-user-privilege-set/>
+                    <d:supported-report-set/>
+                    <cs:getctag />
+                    <c:supported-calendar-component-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        $response = self::$client->request('PROPFIND', $href, [
+            'headers' => [
+                "Depth" => "0",
+            ],
+            'body' => $body,
+        ]);
+        $this->assertEquals(207, $response->getStatusCode());
+        $data = $response->getBody();
+        $this->assertStringContainsString("<d:href>$href</d:href>", $data);
+    }
+
+    /**
+     * Thunderbird does this and relies on the WWW-Authenticate header response to
+     * start sending authenticated requests.
+     *
+     * @depends testDiscoverCalendars
+     */
+    public function testPropfindCalendarWithoutAuth($href)
+    {
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <d:resourcetype />
+                    <d:owner/>
+                    <d:current-user-principal/>
+                    <d:current-user-privilege-set/>
+                    <d:supported-report-set/>
+                    <cs:getctag />
+                    <c:supported-calendar-component-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        $response = self::$client->request('PROPFIND', $href, [
+            'headers' => [
+                "Depth" => "0",
+            ],
+            'body' => $body,
+            'auth' => []
+        ]);
+        $this->assertEquals(401, $response->getStatusCode());
+        $this->assertStringContainsString('Basic realm=', $response->getHeader('WWW-Authenticate')[0]);
+        $data = $response->getBody();
+        $this->assertStringContainsString("<s:exception>Sabre\DAV\Exception\NotAuthenticated</s:exception>", $data);
+    }
+
+    /**
+    * Required for MacOS autoconfig
+    */
+    public function testOptions()
+    {
+        $user = self::$user;
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <d:resourcetype />
+                    <d:displayname />
+                    <cs:getctag />
+                    <c:supported-calendar-component-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        $response = self::$client->request('OPTIONS', "/iRony/principals/{$user->email}/", ['body' => $body]);
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertStringContainsString('PROPFIND', $response->getHeader('Allow')[0]);
+    }
+
+    public function testWellKnown()
+    {
+        $user = self::$user;
+        $body = <<<EOF
+            <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
+                <d:prop>
+                    <d:resourcetype />
+                    <d:displayname />
+                    <cs:getctag />
+                    <c:supported-calendar-component-set />
+                </d:prop>
+            </d:propfind>
+        EOF;
+
+        // The base URL needs to work as a redirect
+        $response = self::$client->request('PROPFIND', '/.well-known/caldav', [
+            'headers' => [
+                "Depth" => "infinity",
+            ],
+            'body' => $body,
+            'allow_redirects' => false
+        ]);
+        $this->assertEquals(301, $response->getStatusCode());
+        $redirectTarget = $response->getHeader('location')[0];
+        $this->assertEquals(\config('services.dav.uri') . "iRony/", $redirectTarget);
+
+        // Follow the redirect
+        $response = self::$client->request('PROPFIND', $redirectTarget, [
+            'headers' => [
+                "Depth" => "infinity",
+            ],
+            'body' => $body,
+            'allow_redirects' => false
+        ]);
+        $this->assertEquals(207, $response->getStatusCode());
+
+        // Any URL should result in a redirect to the same path
+        $response = self::$client->request('PROPFIND', "/.well-known/caldav/calendars/{$user->email}", [
+            'headers' => [
+                "Depth" => "infinity",
+            ],
+            'body' => $body,
+            'allow_redirects' => false
+        ]);
+        $this->assertEquals(301, $response->getStatusCode());
+        $redirectTarget = $response->getHeader('location')[0];
+        //FIXME we have an extra slash that we don't technically want here
+        $this->assertEquals(\config('services.dav.uri') . "iRony//calendars/{$user->email}", $redirectTarget);
+
+        // Follow the redirect
+        $response = self::$client->request('PROPFIND', $redirectTarget, [
+            'headers' => [
+                "Depth" => "infinity",
+            ],
+            'body' => $body,
+            'allow_redirects' => false
+        ]);
+        $this->assertEquals(207, $response->getStatusCode());
+        $data = $response->getBody();
+        $this->assertStringContainsString("<d:href>/iRony/calendars/{$user->email}/</d:href>", $data);
+    }
+
+    /**
+     * @doesNotPerformAssertions
+     */
+    public function testCleanup(): void
+    {
+        $this->deleteTestUser(self::$user->email);
+    }
+}
diff --git a/src/tests/Infrastructure/RoundcubeTest.php b/src/tests/Infrastructure/RoundcubeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Infrastructure/RoundcubeTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Tests\Infrastructure;
+
+use Tests\Browser;
+use Tests\TestCaseDusk;
+
+class RoundcubeTest extends TestCaseDusk
+{
+    private static ?\App\User $user = null;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+        Browser::$baseUrl = \config("services.webmail.uri");
+
+        if (!self::$user) {
+            self::$user = $this->getTestUser('roundcubetesttest@kolab.org', ['password' => 'simple123'], true);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+    }
+
+    public function testLogin()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit('/')
+            ->type('#rcmloginuser', self::$user->email)
+            ->type('#rcmloginpwd', "simple123")
+            ->press('#rcmloginsubmit')
+            ->waitFor('#logo')
+            ->waitUntil('!rcmail.busy')
+            ->assertSee('Inbox');
+
+            $browser->press('.contacts')
+            ->waitUntil('!rcmail.busy')
+            ->assertVisible('#directorylist')
+            ->assertVisible('.addressbook.personal')
+            ->assertSee('Contacts');
+
+            $browser->press('.button-calendar')
+            ->waitUntil('!rcmail.busy')
+            ->assertSee('Calendar');
+
+            //TODO requires the default folders to be created
+            // $browser->press('.button-files')
+            // ->waitUntil('!rcmail.busy')
+            // ->assertSeeIn('#files-folder-list', 'Files');
+
+            $browser->press('.button-notes')
+            ->waitUntil('!rcmail.busy')
+            ->assertSeeIn('#notebooks-content', 'Notes');
+
+            $browser->press('.button-tasklist')
+            ->waitUntil('!rcmail.busy')
+            ->assertSee('Tasks');
+
+            $browser->press('.settings')
+            ->waitUntil('!rcmail.busy')
+            ->assertSee('Activesync');
+            // print($browser->dump());
+        });
+    }
+
+    /**
+     * @doesNotPerformAssertions
+     */
+    public function testCleanup(): void
+    {
+        $this->deleteTestUser(self::$user->email);
+    }
+}
diff --git a/src/tests/Infrastructure/WOPITest.php b/src/tests/Infrastructure/WOPITest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Infrastructure/WOPITest.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Tests\Infrastructure;
+
+use Tests\TestCase;
+
+class WOPITest extends TestCase
+{
+    private static ?\GuzzleHttp\Client $client = null;
+    private static ?\App\User $user = null;
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (!self::$user) {
+            self::$user = $this->getTestUser('wopitest@kolab.org', ['password' => 'simple123'], true);
+        }
+
+        if (!self::$client) {
+            self::$client = new \GuzzleHttp\Client([
+                'base_uri' => \config('services.wopi.uri'),
+                'verify' => false,
+                'auth' => [self::$user->email, 'simple123'],
+                'connect_timeout' => 10,
+                'timeout' => 10
+            ]);
+        }
+    }
+
+    public function testAccess()
+    {
+        $response = self::$client->request('GET', 'api/?method=authenticate&version=4');
+        $this->assertEquals($response->getStatusCode(), 200);
+        $json = json_decode($response->getBody(), true);
+
+        $this->assertEquals('OK', $json['status']);
+        $token = $json['result']['token'];
+        $this->assertTrue(!empty($token));
+
+        //FIXME the session token doesn't seem to be required here?
+        $response = self::$client->request('GET', 'api/?method=mimetypes', [
+            'headers' => [
+                'X-Session_token' => $token
+            ]
+        ]);
+        $this->assertEquals($response->getStatusCode(), 200);
+        $json = json_decode($response->getBody(), true);
+        $this->assertEquals('OK', $json['status']);
+        $this->assertEquals('OK', $json['status']);
+        $this->assertContains('image/png', $json['result']['view']);
+        $this->assertArrayHasKey('text/plain', $json['result']['edit']);
+    }
+
+    /**
+     * @doesNotPerformAssertions
+     */
+    public function testCleanup(): void
+    {
+        $this->deleteTestUser(self::$user->email);
+    }
+}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -2,6 +2,7 @@
 
 namespace Tests;
 
+use App\Backends\IMAP;
 use App\Backends\LDAP;
 use App\CompanionApp;
 use App\Domain;
@@ -403,7 +404,12 @@
             return;
         }
 
-        LDAP::deleteUser($user);
+        if (\config('app.with_imap')) {
+            IMAP::deleteUser($user);
+        }
+        if (\config('app.with_ldap')) {
+            LDAP::deleteUser($user);
+        }
 
         $user->forceDelete();
     }
@@ -553,10 +559,12 @@
      *
      * @coversNothing
      */
-    protected function getTestUser($email, $attrib = [])
+    protected function getTestUser($email, $attrib = [], $createInBackends = false)
     {
         // Disable jobs (i.e. skip LDAP oprations)
-        Queue::fake();
+        if (!$createInBackends) {
+            Queue::fake();
+        }
         $user = User::firstOrCreate(['email' => $email], $attrib);
 
         if ($user->trashed()) {