diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
index 21f60c6f..cef28caf 100644
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -1,588 +1,648 @@
'urn:ietf:params:xml:ns:caldav',
self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
protected $url;
protected $user;
protected $password;
protected $responseHeaders = [];
protected $homes;
/**
* Object constructor
*/
public function __construct($user, $password, $url = null)
{
$this->url = $url ?: \config('services.dav.uri');
$this->user = $user;
$this->password = $password;
}
/**
* Discover DAV home (root) collection of a specified type.
*
* @return array|false Home locations or False on error
*/
public function discover()
{
if (is_array($this->homes)) {
return $this->homes;
}
$path = parse_url($this->url, PHP_URL_PATH);
$body = ''
. ''
. ''
. ''
. ''
. '';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get current-user-principal from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
$principal_href = '';
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('current-user-principal') as $prop) {
$principal_href = $prop->nodeValue;
break;
}
}
if ($path && str_starts_with($principal_href, $path)) {
$principal_href = substr($principal_href, strlen($path));
}
$ns = [
'xmlns:d="DAV:"',
'xmlns:cal="urn:ietf:params:xml:ns:caldav"',
'xmlns:card="urn:ietf:params:xml:ns:carddav"',
];
$body = ''
. ''
. ''
. ''
. ''
. ''
. ''
. '';
$response = $this->request($principal_href, 'PROPFIND', $body);
if (empty($response)) {
\Log::error("Failed to get home collections from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
$homes = [];
if ($element = $response->getElementsByTagName('response')->item(0)) {
if ($prop = $element->getElementsByTagName('prop')->item(0)) {
foreach ($prop->childNodes as $home) {
if ($home->firstChild && $home->firstChild->localName == 'href') {
$href = $home->firstChild->nodeValue;
if ($path && str_starts_with($href, $path)) {
$href = substr($href, strlen($path));
}
$homes[$home->localName] = $href;
}
}
}
}
return $this->homes = $homes;
}
/**
* Get user home folder of specified type
*
* @param string $type Home type or component name
*
* @return string|null Folder location href
*/
public function getHome($type)
{
$options = [
self::TYPE_VEVENT => 'calendar-home-set',
self::TYPE_VTODO => 'calendar-home-set',
self::TYPE_VCARD => 'addressbook-home-set',
self::TYPE_NOTIFICATION => 'notification-URL',
];
$homes = $this->discover();
if (is_array($homes) && isset($options[$type])) {
return $homes[$options[$type]] ?? null;
}
return null;
}
/**
* Check if we can connect to the DAV server
*
* @return bool True on success, False otherwise
*/
public static function healthcheck(): bool
{
// TODO
return true;
}
/**
* Get list of folders of specified type.
*
* @param string $component Component to filter by (VEVENT, VTODO, VCARD)
*
* @return false|array List of folders' metadata or False on error
*/
public function listFolders(string $component)
{
$root_href = $this->getHome($component);
if ($root_href === null) {
return false;
}
$ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"';
$props = '';
if ($component != self::TYPE_VCARD) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"';
$props = ''
. ''
. '';
}
$body = ''
. ''
. ''
. ''
. ''
. ''
. $props
. ''
. '';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request($root_href, 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
return false;
}
$folders = [];
foreach ($response->getElementsByTagName('response') as $element) {
$folder = DAV\Folder::fromDomElement($element);
// Note: Addressbooks don't have 'type' specified
if (
($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
|| in_array($component, $folder->components)
) {
$folders[] = $folder;
}
}
return $folders;
}
/**
* Create a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function create(DAV\CommonObject $object)
{
$headers = ['Content-Type' => $object->contentType];
$content = (string) $object;
if (!strlen($content)) {
throw new \Exception("Cannot PUT an empty DAV object");
}
$response = $this->request($object->href, 'PUT', $content, $headers);
if ($response !== false) {
if (!empty($this->responseHeaders['ETag'])) {
$etag = $this->responseHeaders['ETag'][0];
if (preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
$object->etag = $etag;
}
return $object;
}
return false;
}
/**
* Update a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function update(DAV\CommonObject $object)
{
return $this->create($object);
}
/**
* Delete a DAV object from a folder
*
* @param string $location Object location
*
* @return bool True on success, False on error
*/
public function delete(string $location)
{
$response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']);
return $response !== false;
}
/**
* Create a DAV folder (collection)
*
* @param DAV\Folder $folder Folder object
*
* @return bool True on success, False on error
*/
public function folderCreate(DAV\Folder $folder)
{
$response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol'));
return $response !== false;
}
/**
* Delete a DAV folder (collection)
*
* @param string $location Folder location
*
* @return bool True on success, False on error
*/
public function folderDelete($location)
{
$response = $this->request($location, 'DELETE');
return $response !== false;
}
/**
* Get all properties of a folder.
*
* @param string $location Object location
*
* @return false|DAV\Folder Folder metadata or False on error
*/
public function folderInfo(string $location)
{
$body = DAV\Folder::propfindXML();
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']);
if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) {
return DAV\Folder::fromDomElement($element);
}
return false;
}
/**
* Update a DAV folder (collection)
*
* @param DAV\Folder $folder Folder object
*
* @return bool True on success, False on error
*/
public function folderUpdate(DAV\Folder $folder)
{
// Note: Changing resourcetype property is forbidden (at least by Cyrus)
$response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate'));
return $response !== false;
}
+ /**
+ * Initialize default DAV folders (collections)
+ *
+ * @param \App\User $user User object
+ *
+ * @throws \Exception
+ */
+ public static function initDefaultFolders(\App\User $user): void
+ {
+ if (!\config('services.dav.uri')) {
+ return;
+ }
+
+ $folders = \config('services.dav.default_folders');
+ if (!count($folders)) {
+ return;
+ }
+
+ // Cyrus DAV does not support proxy authorization via DAV. Even though it has
+ // the Authorize-As header, it is used only for cummunication with Murder backends.
+ // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time.
+ $password = \App\Auth\Utils::tokenCreate((string) $user->id);
+
+ if ($password === null) {
+ throw new \Exception("Failed to create an authentication token for DAV");
+ }
+
+ $dav = new self($user->email, $password);
+
+ foreach ($folders as $props) {
+ $folder = new DAV\Folder();
+ $folder->href = $props['type'] . 's' . '/user/' . $user->email . '/' . $props['path'];
+ $folder->types = ['collection', $props['type']];
+ $folder->name = $props['displayname'] ?? '';
+ $folder->components = $props['components'] ?? [];
+
+ $existing = null;
+ try {
+ $existing = $dav->folderInfo($folder->href);
+ } catch (RequestException $e) {
+ // Cyrus DAV returns 503 Service Unavailable on a non-existing location (?)
+ if ($e->getCode() != 503 && $e->getCode() != 404) {
+ throw $e;
+ }
+ }
+
+ // folder already exists? check the properties and update if needed
+ if ($existing) {
+ if ($existing->name != $folder->name || $existing->components != $folder->components) {
+ if (!$dav->folderUpdate($folder)) {
+ throw new \Exception("Failed to update DAV folder {$folder->href}");
+ }
+ }
+ } elseif (!$dav->folderCreate($folder)) {
+ throw new \Exception("Failed to create DAV folder {$folder->href}");
+ }
+ }
+ }
+
/**
* Check server options (and authentication)
*
* @return false|array DAV capabilities on success, False on error
*/
public function options()
{
$response = $this->request('', 'OPTIONS');
if ($response !== false) {
return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? []));
}
return false;
}
/**
* Search DAV objects in a folder.
*
* @param string $location Folder location
* @param DAV\Search $search Search request parameters
* @param callable $callback A callback to execute on every item
*
* @return false|array List of objects on success, False on error
*/
public function search(string $location, DAV\Search $search, $callback = null)
{
$headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal'];
$response = $this->request($location, 'REPORT', $search, $headers);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$object = $this->objectFromElement($element, $search->component);
if ($callback) {
$object = $callback($object);
}
if ($object) {
if (is_array($object)) {
$objects[$object[0]] = $object[1];
} else {
$objects[] = $object;
}
}
}
return $objects;
}
/**
* Fetch DAV objects data from a folder
*
* @param string $location Folder location
* @param string $component Object type (VEVENT, VTODO, VCARD)
* @param array $hrefs List of objects' locations to fetch
*
* @return false|array Objects metadata on success, False on error
*/
public function getObjects(string $location, string $component, array $hrefs = [])
{
if (empty($hrefs)) {
return [];
}
$body = '';
foreach ($hrefs as $href) {
$body .= '' . $href . '';
}
$queries = [
self::TYPE_VEVENT => 'calendar-multiget',
self::TYPE_VTODO => 'calendar-multiget',
self::TYPE_VCARD => 'addressbook-multiget',
];
$types = [
self::TYPE_VEVENT => 'calendar-data',
self::TYPE_VTODO => 'calendar-data',
self::TYPE_VCARD => 'address-data',
];
$body = ''
. ' '
. ''
. ''
. ''
. ''
. $body
. '';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
return $objects;
}
/**
* Parse XML content
*/
protected function parseXML($xml)
{
$doc = new \DOMDocument('1.0', 'UTF-8');
if (str_starts_with($xml, 'loadXML($xml)) {
throw new \Exception("Failed to parse XML");
}
$doc->formatOutput = true;
}
return $doc;
}
/**
* Parse request/response body for debug purposes
*/
protected function debugBody($body, $headers)
{
$head = '';
foreach ($headers as $header_name => $header_value) {
if (is_array($header_value)) {
$header_value = implode("\n\t", $header_value);
}
$head .= "{$header_name}: {$header_value}\n";
}
if ($body instanceof DAV\CommonObject) {
$body = (string) $body;
}
if (str_starts_with($body, 'formatOutput = true;
$doc->preserveWhiteSpace = false;
if (!$doc->loadXML($body)) {
throw new \Exception("Failed to parse XML");
}
$body = $doc->saveXML();
}
return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : '');
}
/**
* Create DAV\CommonObject from a DOMElement
*/
protected function objectFromElement($element, $component)
{
switch ($component) {
case self::TYPE_VEVENT:
$object = DAV\Vevent::fromDomElement($element);
break;
case self::TYPE_VTODO:
$object = DAV\Vtodo::fromDomElement($element);
break;
case self::TYPE_VCARD:
$object = DAV\Vcard::fromDomElement($element);
break;
default:
throw new \Exception("Unknown component: {$component}");
}
return $object;
}
/**
* Execute HTTP request to a DAV server
*/
protected function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
$url = $this->url;
$this->responseHeaders = [];
if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) {
$path = substr($path, strlen($rootPath));
}
$url .= $path;
$client = Http::withBasicAuth($this->user, $this->password)
// ->withToken($token) // Bearer token
->withOptions(['verify' => \config('services.dav.verify')]);
if ($body) {
if (!isset($headers['Content-Type'])) {
$headers['Content-Type'] = 'application/xml; charset=utf-8';
}
$client->withBody($body, $headers['Content-Type']);
}
if (!empty($headers)) {
$client->withHeaders($headers);
}
if ($debug) {
$body = $this->debugBody($body, $headers);
\Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : ''));
}
$response = $client->send($method, $url);
$body = $response->body();
$code = $response->status();
if ($debug) {
\Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers()));
}
// Throw an exception if a client or server error occurred...
$response->throw();
$this->responseHeaders = $response->headers();
return $this->parseXML($body);
}
}
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
index c86c6f08..dba51da0 100644
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -1,155 +1,160 @@
getElementsByTagName('href')->item(0)) {
$folder->href = $href->nodeValue;
}
if ($color = $element->getElementsByTagName('calendar-color')->item(0)) {
if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) {
$folder->color = substr($color->nodeValue, 1);
}
}
if ($name = $element->getElementsByTagName('displayname')->item(0)) {
$folder->name = $name->nodeValue;
}
if ($ctag = $element->getElementsByTagName('getctag')->item(0)) {
$folder->ctag = $ctag->nodeValue;
}
$components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
foreach ($set_element->getElementsByTagName('comp') as $comp) {
$components[] = $comp->attributes->getNamedItem('name')->nodeValue;
}
}
$types = [];
if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) {
foreach ($type_element->childNodes as $node) {
if ($node->nodeType == XML_ELEMENT_NODE) {
$_type = explode(':', $node->nodeName);
$types[] = count($_type) > 1 ? $_type[1] : $_type[0];
}
}
}
$folder->types = $types;
$folder->components = $components;
return $folder;
}
/**
* Parse folder properties input into XML string to use in a request
*
* @return string
*/
public function toXML($tag)
{
$ns = 'xmlns:d="DAV:"';
$props = '';
$type = null;
if (in_array('addressbook', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"';
$type = 'addressbook';
} elseif (in_array('calendar', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
$type = 'calendar';
}
- $props .= '' . ($type ? "" : '') . '';
+ // Cyrus DAV does not allow resourcetype property change
+ if ($tag != 'propertyupdate') {
+ $props .= '' . ($type ? "" : '') . '';
+ }
if (!empty($this->components)) {
- $props .= '';
+ // Note: Normally Cyrus DAV does not allow supported-calendar-component-set property update,
+ // but I found in Cyrus code that the action can be forced with force=yes attribute.
+ $props .= '';
foreach ($this->components as $component) {
$props .= '';
}
$props .= '';
}
if ($this->name !== null) {
$props .= '' . htmlspecialchars($this->name, ENT_XML1, 'UTF-8') . '';
}
if ($this->color !== null) {
$color = $this->color;
if (strlen($color) && $color[0] != '#') {
$color = '#' . $color;
}
$ns .= ' xmlns:a="http://apple.com/ns/ical/"';
$props .= '' . htmlspecialchars($color, ENT_XML1, 'UTF-8') . '';
}
return ''
. "{$props}";
}
/**
* Get XML string for PROPFIND query on a folder
*
* @return string
*/
public static function propfindXML()
{
$ns = implode(' ', [
'xmlns:d="DAV:"',
// 'xmlns:cs="http://calendarserver.org/ns/"',
'xmlns:c="urn:ietf:params:xml:ns:caldav"',
// 'xmlns:a="http://apple.com/ns/ical/"',
// 'xmlns:k="Kolab:"'
]);
// Note: does not include some of the properties we're interested in
return ''
. ''
. ''
// . ''
. ''
// . ''
// . ''
// . ''
. ''
. ''
// . ''
. ''
. '';
}
}
diff --git a/src/app/Backends/Helper.php b/src/app/Backends/Helper.php
new file mode 100644
index 00000000..d850b8d0
--- /dev/null
+++ b/src/app/Backends/Helper.php
@@ -0,0 +1,55 @@
+ 'Default',
+ 'displayname' => 'Calendar',
+ 'components' => ['VEVENT'],
+ 'type' => 'calendar',
+ ],
+ [
+ 'path' => 'Tasks',
+ 'displayname' => 'Tasks',
+ 'components' => ['VTODO'],
+ 'type' => 'calendar',
+ ],
+ [
+ // FIXME: Same here, should we use 'Contacts'?
+ 'path' => 'Default',
+ 'displayname' => 'Contacts',
+ 'type' => 'addressbook',
+ ],
+ ];
+ }
+
+ /**
+ * List of default IMAP folders
+ */
+ public static function defaultImapFolders(): array
+ {
+ // TODO: Move the list from config/imap.php
+ return [];
+ }
+}
diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php
index 16d09b85..08b7da93 100644
--- a/src/app/Jobs/User/CreateJob.php
+++ b/src/app/Jobs/User/CreateJob.php
@@ -1,120 +1,123 @@
isDeleted()`), or
* * the user is actually deleted (`$user->deleted_at`), or
* * the user is already marked as ready in LDAP (`$user->isLdapReady()`).
*/
class CreateJob extends UserJob
{
/** @var int Enable waiting for a user record to exist */
protected $waitForUser = 5;
/**
* Execute the job.
*
* @return void
*
* @throws \Exception
*/
public function handle()
{
$user = $this->getUser();
if (!$user) {
return;
}
if ($user->role) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
if ($user->email == \config('imap.admin_login')) {
// Ignore Cyrus admin account
return;
}
// sanity checks
if ($user->isDeleted()) {
$this->fail(new \Exception("User {$this->userId} is marked as deleted."));
return;
}
if ($user->trashed()) {
$this->fail(new \Exception("User {$this->userId} is actually deleted."));
return;
}
$withLdap = \config('app.with_ldap');
// see if the domain is ready
$domain = $user->domain();
if (!$domain) {
$this->fail(new \Exception("The domain for {$this->userId} does not exist."));
return;
}
if ($domain->isDeleted()) {
$this->fail(new \Exception("The domain for {$this->userId} is marked as deleted."));
return;
}
if ($withLdap && !$domain->isLdapReady()) {
$this->release(60);
return;
}
if (\config('abuse.suspend_enabled') && !$user->isSuspended()) {
$code = \Artisan::call("user:abuse-check {$this->userId}");
if ($code == 2) {
\Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}");
\App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, "Suspected spammer");
$user->status |= \App\User::STATUS_SUSPENDED;
}
}
if ($withLdap && !$user->isLdapReady()) {
\App\Backends\LDAP::createUser($user);
$user->status |= \App\User::STATUS_LDAP_READY;
$user->save();
}
if (!$user->isImapReady()) {
if (\config('app.with_imap')) {
if (!\App\Backends\IMAP::createUser($user)) {
throw new \Exception("Failed to create mailbox for user {$this->userId}.");
}
} else {
if (!\App\Backends\IMAP::verifyAccount($user->email)) {
$this->release(15);
return;
}
}
$user->status |= \App\User::STATUS_IMAP_READY;
}
+ // FIXME: Should we ignore exceptions on this operation or introduce DAV_READY status?
+ \App\Backends\DAV::initDefaultFolders($user);
+
// Make user active in non-mandate mode only
if (
!($wallet = $user->wallet())
|| !($plan = $user->wallet()->plan())
|| $plan->mode != \App\Plan::MODE_MANDATE
) {
$user->status |= \App\User::STATUS_ACTIVE;
}
$user->save();
}
}
diff --git a/src/config/services.php b/src/config/services.php
index d84bcaf1..d9b2f134 100644
--- a/src/config/services.php
+++ b/src/config/services.php
@@ -1,79 +1,80 @@
[
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
],
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'sparkpost' => [
'secret' => env('SPARKPOST_SECRET'),
],
'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'),
'mollie' => [
'key' => env('MOLLIE_KEY'),
],
'stripe' => [
'key' => env('STRIPE_KEY'),
'public_key' => env('STRIPE_PUBLIC_KEY'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
'coinbase' => [
'key' => env('COINBASE_KEY'),
'webhook_secret' => env('COINBASE_WEBHOOK_SECRET'),
'api_verify_tls' => env('COINBASE_VERIFY_TLS', true),
],
'openexchangerates' => [
'api_key' => env('OPENEXCHANGERATES_API_KEY', null),
],
'dav' => [
'uri' => env('DAV_URI', 'https://proxy/'),
+ 'default_folders' => \App\Backends\Helper::defaultDavFolders(),
'verify' => (bool) env('DAV_VERIFY', true),
],
'autodiscover' => [
'uri' => env('AUTODISCOVER_URI', env('APP_URL', 'http://localhost')),
],
'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/phpunit.xml b/src/phpunit.xml
index 9cb99bdd..cb01f1d9 100644
--- a/src/phpunit.xml
+++ b/src/phpunit.xml
@@ -1,47 +1,48 @@
tests/Unit
tests/Functional
tests/Feature
tests/Browser
tests/Browser/PaymentCoinbaseTest.php
./app
+
diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php
new file mode 100644
index 00000000..c0845194
--- /dev/null
+++ b/src/tests/Feature/Backends/DAVTest.php
@@ -0,0 +1,100 @@
+markTestSkipped();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ if ($this->user) {
+ $this->deleteTestUser($this->user->email);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test initializing default folders for a user.
+ *
+ * @group imap
+ * @group dav
+ */
+ public function testInitDefaultFolders(): void
+ {
+ Queue::fake();
+
+ $props = ['password' => 'test-pass'];
+ $this->user = $user = $this->getTestUser('davtest-' . time() . '@' . \config('app.domain'), $props);
+
+ // Create the IMAP mailbox, it is required otherwise DAV requests will fail
+ \config(['imap.default_folders' => null]);
+ IMAP::createUser($user);
+
+ $dav_folders = [
+ [
+ 'path' => 'Default',
+ 'displayname' => 'Calendar-Test',
+ 'components' => ['VEVENT'],
+ 'type' => 'calendar',
+ ],
+ [
+ 'path' => 'Tasks',
+ 'displayname' => 'Tasks-Test',
+ 'components' => ['VTODO'],
+ 'type' => 'calendar',
+ ],
+ [
+ 'path' => 'Default',
+ 'displayname' => 'Contacts-Test',
+ 'type' => 'addressbook',
+ ],
+ ];
+
+ \config(['services.dav.default_folders' => $dav_folders]);
+ DAV::initDefaultFolders($user);
+
+ $dav = new DAV($user->email, $props['password']);
+
+ $folders = $dav->listFolders(DAV::TYPE_VCARD);
+ $this->assertCount(1, $folders);
+ $this->assertSame('Contacts-Test', $folders[0]->name);
+
+ $folders = $dav->listFolders(DAV::TYPE_VEVENT);
+ $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; });
+ $folders = array_values($folders);
+ $this->assertCount(1, $folders);
+ $this->assertSame(['VEVENT'], $folders[0]->components);
+ $this->assertSame(['collection', 'calendar'], $folders[0]->types);
+ $this->assertSame('Calendar-Test', $folders[0]->name);
+
+ $folders = $dav->listFolders(DAV::TYPE_VTODO);
+ $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; });
+ $folders = array_values($folders);
+ $this->assertCount(1, $folders);
+ $this->assertSame(['VTODO'], $folders[0]->components);
+ $this->assertSame(['collection', 'calendar'], $folders[0]->types);
+ $this->assertSame('Tasks-Test', $folders[0]->name);
+ }
+}