Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117900349
D5172.1775379681.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
147 KB
Referenced Files
None
Subscribers
None
D5172.1775379681.diff
View Options
diff --git a/docker/postfix/rootfs/etc/postfix/main.cf b/docker/postfix/rootfs/etc/postfix/main.cf
--- a/docker/postfix/rootfs/etc/postfix/main.cf
+++ b/docker/postfix/rootfs/etc/postfix/main.cf
@@ -561,6 +561,7 @@
# Outbound
submission_data_restrictions =
+ check_policy_service unix:private/policy_submission
check_policy_service unix:private/policy_ratelimit
submission_client_restrictions =
#reject_unknown_reverse_client_hostname,
diff --git a/docker/postfix/rootfs/etc/postfix/master.cf b/docker/postfix/rootfs/etc/postfix/master.cf
--- a/docker/postfix/rootfs/etc/postfix/master.cf
+++ b/docker/postfix/rootfs/etc/postfix/master.cf
@@ -172,6 +172,10 @@
policy_ratelimit unix - n n - - spawn
user=nobody argv=/usr/libexec/postfix/kolab_policy_ratelimit
+# Outbound
+policy_submission unix - n n - - spawn
+ user=nobody argv=/usr/libexec/postfix/kolab_policy_submission
+
# Inbound
policy_greylist unix - n n - - spawn
user=nobody argv=/usr/libexec/postfix/kolab_policy_greylist
diff --git a/extras/kolab_policy_ratelimit b/extras/kolab_policy_ratelimit
--- a/extras/kolab_policy_ratelimit
+++ b/extras/kolab_policy_ratelimit
@@ -3,7 +3,7 @@
This policy applies rate limitations
To manually test this you can issue something like this (see https://www.postfix.org/SMTPD_POLICY_README.html from more info on the protocol):
-echo -e "request=smtpd_access_policy\nsender=test1@kolab.org\nrecipient=test2@kolab.org\ninstance=testinstance\nprotocol_state=RCPT\n\n" | /usr/libexec/postfix/kolab_policy_ratelimit
+echo -e "request=smtpd_access_policy\nsender=test1@kolab.org\nrecipient=test2@kolab.org\ninstance=testinstance\nprotocol_state=DATA\n\n" | /usr/libexec/postfix/kolab_policy_ratelimit
"""
diff --git a/extras/kolab_policy_ratelimit b/extras/kolab_policy_submission
copy from extras/kolab_policy_ratelimit
copy to extras/kolab_policy_submission
--- a/extras/kolab_policy_ratelimit
+++ b/extras/kolab_policy_submission
@@ -1,16 +1,15 @@
#!/usr/bin/python3
"""
-This policy applies rate limitations
+This policy applies submission access policies
To manually test this you can issue something like this (see https://www.postfix.org/SMTPD_POLICY_README.html from more info on the protocol):
-echo -e "request=smtpd_access_policy\nsender=test1@kolab.org\nrecipient=test2@kolab.org\ninstance=testinstance\nprotocol_state=RCPT\n\n" | /usr/libexec/postfix/kolab_policy_ratelimit
+echo -e "request=smtpd_access_policy\nsasl_sender=test1@kolab.org\nsender=test1@kolab.org\nrecipient=test2@kolab.org\ninstance=testinstance\nprotocol_state=DATA\n\n" | /usr/libexec/postfix/kolab_policy_submission
"""
import json
import time
import sys
-
import requests
@@ -18,9 +17,9 @@
"""
A holder of policy request instances.
"""
- db = None
recipients = []
sender = None
+ user = None
def __init__(self, request):
"""
@@ -30,26 +29,27 @@
self.sender = request['sender']
if 'recipient' in request:
- request['recipient'] = request['recipient']
-
self.recipients.append(request['recipient'])
+ if 'sasl_sender' in request:
+ self.user = request['sasl_sender']
+ elif 'sasl_username' in request:
+ self.user = request['sasl_username']
+
def add_request(self, request):
"""
Add an additional request from an instance to the existing instance
"""
# Normalize email addresses (they may contain recipient delimiters)
if 'recipient' in request:
- request['recipient'] = request['recipient']
-
if not request['recipient'].strip() == '':
self.recipients.append(request['recipient'])
- def check_rate(self):
+ def check_policy(self):
"""
- Check the rates at which this sender is hitting our mailserver.
+ Pass the request to Kolab API
"""
- if self.sender == "":
+ if self.sender == "" or self.user is None:
return {'response': 'DUNNO'}
try:
@@ -57,7 +57,8 @@
URL,
data={
'sender': self.sender,
- 'recipients': self.recipients
+ 'recipients': self.recipients,
+ 'user': self.user
},
verify=True
)
@@ -70,14 +71,12 @@
return response
-
def read_request_input():
"""
Read a single policy request from sys.stdin, and return a dictionary
containing the request.
"""
start_time = time.time()
-
policy_request = {}
end_of_request = False
@@ -104,7 +103,7 @@
if __name__ == "__main__":
- URL = 'https://services.kolabnow.com/api/webhooks/policy/ratelimit'
+ URL = 'https://services.kolabnow.com/api/webhooks/policy/submission'
POLICY_REQUESTS = {}
@@ -126,7 +125,7 @@
sys.stdout.flush()
sys.exit(0)
else:
- RESPONSE = POLICY_REQUESTS[INSTANCE].check_rate()
+ RESPONSE = POLICY_REQUESTS[INSTANCE].check_policy()
try:
R = json.loads(RESPONSE.text)
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
@@ -2,6 +2,7 @@
namespace App\Backends;
+use App\User;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
@@ -18,12 +19,6 @@
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
- public const SHARING_READ = 'read';
- public const SHARING_READ_WRITE = 'read-write';
- public const SHARING_NO_ACCESS = 'no-access';
- public const SHARING_OWNER = 'shared-owner';
- public const SHARING_NOT_SHARED = 'not-shared';
-
protected $url;
protected $user;
protected $password;
@@ -208,31 +203,10 @@
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 = '<c:supported-calendar-component-set />'
- . '<a:calendar-color />'
- . '<k:alarms />';
- }
-
- $body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:propfind ' . $ns . '>'
- . '<d:prop>'
- . '<d:resourcetype />'
- . '<d:displayname />'
- . '<d:owner/>'
- . '<cs:getctag />'
- . $props
- . '</d:prop>'
- . '</d:propfind>';
-
// 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);
+ $response = $this->request($root_href, 'PROPFIND', DAV\Folder::propfindXML(), $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
@@ -382,14 +356,32 @@
return $response !== false;
}
+ /**
+ * Get a single DAV notification
+ *
+ * @param string $location Notification href
+ *
+ * @return ?DAV\Notification Notification data on success
+ */
+ public function getNotification($location)
+ {
+ $response = $this->request($location, 'GET');
+
+ if ($response && ($element = $response->getElementsByTagName('notification')->item(0))) {
+ return DAV\Notification::fromDomElement($element, $location);
+ }
+
+ return null;
+ }
+
/**
* Initialize default DAV folders (collections)
*
- * @param \App\User $user User object
+ * @param User $user User object
*
* @throws \Exception
*/
- public static function initDefaultFolders(\App\User $user): void
+ public static function initDefaultFolders(User $user): void
{
if (!\config('services.dav.uri')) {
return;
@@ -400,16 +392,7 @@
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 = self::getInstance($user->email, $password);
+ $dav = self::getClientForUser($user);
foreach ($folders as $props) {
$folder = new DAV\Folder();
@@ -441,6 +424,69 @@
}
}
+ /**
+ * Accept/Deny a share invitation (draft-pot-webdav-resource-sharing)
+ *
+ * @param string $location Notification location
+ * @param DAV\InviteReply $reply Invite reply
+ *
+ * @return bool True on success, False on error
+ */
+ public function inviteReply($location, $reply): bool
+ {
+ $response = $this->request($location, 'POST', $reply, ['Content-Type' => $reply->contentType]);
+
+ return $response !== false;
+ }
+
+ /**
+ * Fetch DAV notifications
+ *
+ * @param array $types Notification types to return
+ *
+ * @return array<DAV\Notification> Notification objects
+ */
+ public function listNotifications(array $types = []): array
+ {
+ $root_href = $this->getHome(self::TYPE_NOTIFICATION);
+
+ if ($root_href === null) {
+ return [];
+ }
+
+ // FIXME: As far as I can see there's no other way to get only the notifications we're interested in
+
+ $body = DAV\Notification::propfindXML();
+
+ $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return [];
+ }
+
+ $objects = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $type = $element->getElementsByTagName('notificationtype')->item(0);
+ if (!$type || !$type->firstChild) {
+ // skip non-notification elements (e.g. a parent folder)
+ continue;
+ }
+
+ $type = $type->firstChild->localName;
+
+ if (empty($types) || in_array($type, $types)) {
+ if ($href = $element->getElementsByTagName('href')->item(0)) {
+ if ($n = $this->getNotification($href->nodeValue)) {
+ $objects[] = $n;
+ }
+ }
+ }
+ }
+
+ return $objects;
+ }
+
/**
* Check server options (and authentication)
*
@@ -508,57 +554,143 @@
}
/**
- * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ * Share default DAV folders with specified user (delegatee)
*
- * @param string $location Resource (folder) location
- * @param array $sharees Map of sharee => privilege
+ * @param User $user The user
+ * @param User $to The delegated user
+ * @param array $acl ACL Permissions per folder type
*
- * @return bool True on success, False on error
+ * @throws \Exception
*/
- public function shareResource(string $location, array $sharees): bool
+ public static function shareDefaultFolders(User $user, User $to, array $acl): void
{
- // TODO: This might need to be configurable or discovered somehow
- $path = '/principals/user/';
- if ($host_path = parse_url($this->url, PHP_URL_PATH)) {
- $path = '/' . trim($host_path, '/') . $path;
+ if (!\config('services.dav.uri')) {
+ return;
}
- $props = '';
+ $folders = [];
+ foreach (\config('services.dav.default_folders') as $folder) {
+ if ($folder['type'] == 'addressbook') {
+ $type = 'contact';
+ } elseif (in_array('VTODO', $folder['components'] ?? [])) {
+ $type = 'task';
+ } elseif (in_array('VEVENT', $folder['components'] ?? [])) {
+ $type = 'event';
+ } else {
+ continue;
+ }
- foreach ($sharees as $href => $sharee) {
- if (!is_array($sharee)) {
- $sharee = ['access' => $sharee];
+ if (!empty($acl[$type])) {
+ $folders[] = [
+ 'href' => $folder['type'] . 's' . '/user/' . $user->email . '/' . $folder['path'],
+ 'acl' => $acl[$type] == 'read-write'
+ ? DAV\ShareResource::ACCESS_READ_WRITE : DAV\ShareResource::ACCESS_READ,
+ ];
}
+ }
- $href = $path . $href;
- $props .= '<d:sharee>'
- . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
- . '<d:share-access><d:' . ($sharee['access'] ?? self::SHARING_NO_ACCESS) . '/></d:share-access>'
- . '<d:' . ($sharee['status'] ?? 'noresponse') . '/>';
+ if (empty($folders)) {
+ return;
+ }
- if (isset($sharee['comment']) && strlen($sharee['comment'])) {
- $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
- }
+ $dav = self::getClientForUser($user);
- if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
- $props .= '<d:prop><d:displayname>'
- . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
- . '</d:displayname></d:prop>';
+ // Create sharing invitations
+ foreach ($folders as $folder) {
+ $share_resource = new DAV\ShareResource();
+ $share_resource->href = $folder['href'];
+ $share_resource->sharees = [$dav->principalLocation($to->email) => $folder['acl']];
+ if (!$dav->shareResource($share_resource)) {
+ throw new \Exception("Failed to share DAV folder {$folder['href']}");
}
-
- $props .= '</d:sharee>';
}
- $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8'];
+ // Accept sharing invitations
+ $dav = self::getClientForUser($to);
- $body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+ // FIXME/TODO: It would be nice to be able to fetch only notifications that are:
+ // - created by the $user
+ // - are invite notification with invite-noresponse, or only these created in last minute
+ // Right now we'll do this filtering here
+ foreach ($dav->listNotifications([DAV\Notification::NOTIFICATION_SHARE_INVITE]) as $n) {
+ if (
+ $n->status == $n::INVITE_NORESPONSE
+ && strpos((string) $n->principal, "/user/{$user->email}")
+ ) {
+ $reply = new DAV\InviteReply();
+ $reply->type = $reply::INVITE_ACCEPTED;
+
+ if (!$dav->inviteReply($n->href, $reply)) {
+ throw new \Exception("Failed to accept DAV share invitation {$n->href}");
+ }
+ }
+ }
+ }
- $response = $this->request($location, 'POST', $body, $headers);
+ /**
+ * Set folder sharing invites (draft-pot-webdav-resource-sharing)
+ *
+ * @param DAV\ShareResource $resource Share resource
+ *
+ * @return bool True on success, False on error
+ */
+ public function shareResource(DAV\ShareResource $resource): bool
+ {
+ $response = $this->request($resource->href, 'POST', $resource, ['Content-Type' => $resource->contentType]);
return $response !== false;
}
+ /**
+ * Unshare folders.
+ *
+ * @param User $user Folders' owner
+ * @param string $email Delegated user
+ *
+ * @throws \Exception
+ */
+ public static function unshareFolders(User $user, string $email): void
+ {
+ $dav = self::getClientForUser($user);
+
+ foreach ([self::TYPE_VEVENT, self::TYPE_VTODO, self::TYPE_VCARD] as $type) {
+ foreach ($dav->listFolders($type) as $folder) {
+ if ($folder->owner === $user->email && isset($folder->invites["mailto:{$email}"])) {
+ $share_resource = new DAV\ShareResource();
+ $share_resource->href = $folder->href;
+ $share_resource->sharees = [$dav->principalLocation($email) => $share_resource::ACCESS_NONE];
+ if (!$dav->shareResource($share_resource)) {
+ throw new \Exception("Failed to unshare DAV folder {$folder->href}");
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Unsubscribe folders shared by other users.
+ *
+ * @param User $user Account owner
+ * @param string $email Other user email address
+ *
+ * @throws \Exception
+ */
+ public static function unsubscribeSharedFolders(User $user, string $email): void
+ {
+ $dav = self::getClientForUser($user);
+
+ foreach ([self::TYPE_VEVENT, self::TYPE_VTODO, self::TYPE_VCARD] as $type) {
+ foreach ($dav->listFolders($type) as $folder) {
+ if ($folder->owner === $email && $user->email != $email) {
+ $response = $dav->request($folder->href, 'DELETE');
+ if ($response === false) {
+ throw new \Exception("Failed to unsubscribe DAV folder {$folder->href}");
+ }
+ }
+ }
+ }
+ }
+
/**
* Fetch DAV objects data from a folder
*
@@ -574,33 +706,13 @@
return [];
}
- $body = '';
- foreach ($hrefs as $href) {
- $body .= '<d:href>' . $href . '</d: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',
- ];
+ $search = new DAV\Search($component, true, [], true);
+ $search->properties = ['d:getetag'];
+ $search->hrefs = $hrefs;
- $body = '<?xml version="1.0" encoding="utf-8"?>'
- . ' <c:' . $queries[$component] . ' xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
- . '<d:prop>'
- . '<d:getetag />'
- . '<c:' . $types[$component] . ' />'
- . '</d:prop>'
- . $body
- . '</c:' . $queries[$component] . '>';
+ $headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal'];
- $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+ $response = $this->request($location, 'REPORT', $search, $headers);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
@@ -616,6 +728,23 @@
return $objects;
}
+ /**
+ * Create DAV client instance for a user (using generated auth token as password)
+ */
+ protected static function getClientForUser(User $user): DAV
+ {
+ // 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");
+ }
+
+ return self::getInstance($user->email, $password);
+ }
+
/**
* Parse XML content
*/
@@ -691,10 +820,27 @@
return $object;
}
+ /**
+ * Convert user email address into a principal location (href)
+ *
+ * @param string $email Email address
+ */
+ public function principalLocation(string $email): string
+ {
+ // TODO: This might need to be configurable or discovered somehow,
+ // maybe get it from current-user-principal property that we read in discover()
+ $path = '/principals/user/';
+ if ($host_path = parse_url($this->url, PHP_URL_PATH)) {
+ $path = '/' . trim($host_path, '/') . $path;
+ }
+
+ return $path . $email;
+ }
+
/**
* Execute HTTP request to a DAV server
*/
- protected function request($path, $method, $body = '', $headers = [])
+ public function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
$url = $this->url;
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -4,6 +4,11 @@
class Folder
{
+ public const SHARE_ACCESS_NONE = 'not-shared';
+ public const SHARE_ACCESS_SHARED = 'shared-owner';
+ public const SHARE_ACCESS_READ = 'read';
+ public const SHARE_ACCESS_READ_WRITE = 'read-write';
+
/** @var ?string Folder location (href property) */
public $href;
@@ -19,6 +24,9 @@
/** @var array Supported resource types (resourcetype property) */
public $types = [];
+ /** @var ?string Access rights on a shared folder (share-access property) */
+ public $shareAccess;
+
/** @var ?string Folder color (calendar-color property) */
public $color;
@@ -87,6 +95,13 @@
}
}
+ // 'share-access' from draft-pot-webdav-resource-sharing
+ if ($share = $element->getElementsByTagName('share-access')->item(0)) {
+ if ($share->firstChild) {
+ $folder->shareAccess = $share->firstChild->localName;
+ }
+ }
+
// 'invite' from draft-pot-webdav-resource-sharing
if ($invite_element = $element->getElementsByTagName('invite')->item(0)) {
$invites = [];
@@ -106,7 +121,7 @@
if ($access = $sharee->getElementsByTagName('share-access')->item(0)) {
$access = $access->firstChild->localName;
} else {
- $access = \App\Backends\DAV::SHARING_NOT_SHARED;
+ $access = self::SHARE_ACCESS_NONE;
}
$props = [
@@ -198,7 +213,7 @@
{
$ns = implode(' ', [
'xmlns:d="DAV:"',
- // 'xmlns:cs="http://calendarserver.org/ns/"',
+ 'xmlns:cs="http://calendarserver.org/ns/"',
'xmlns:c="urn:ietf:params:xml:ns:caldav"',
'xmlns:a="http://apple.com/ns/ical/"',
// 'xmlns:k="Kolab:"'
@@ -210,11 +225,13 @@
. '<d:prop>'
. '<a:calendar-color/>'
. '<c:supported-calendar-component-set/>'
- // . '<cs:getctag/>'
+ . '<cs:getctag/>'
// . '<d:acl/>'
// . '<d:current-user-privilege-set/>'
. '<d:resourcetype/>'
. '<d:displayname/>'
+ . '<d:share-access/>' // draft-pot-webdav-resource-sharing-04
+ . '<d:owner/>'
. '<d:invite/>'
// . '<k:alarms/>'
. '</d:prop>'
diff --git a/src/app/Backends/DAV/InviteReply.php b/src/app/Backends/DAV/InviteReply.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/InviteReply.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class InviteReply
+{
+ public const INVITE_ACCEPTED = 'accepted';
+ public const INVITE_DECLINED = 'declined';
+
+ /** @var string Object content type (of the string representation) */
+ public $contentType = 'application/davsharing+xml; charset=utf-8';
+
+ /** @var ?string Invite reply type */
+ public $type;
+
+ /** @var ?string Invite reply comment */
+ public $comment;
+
+
+ /**
+ * Create Notification object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ throw new \Exception("Not implemented");
+ }
+
+ /**
+ * Convert an invite reply into an XML string to use in a request
+ */
+ public function __toString(): string
+ {
+ $reply = '<d:invite-' . ($this->type ?: self::INVITE_ACCEPTED) . '/>';
+
+ // Note: <create-in> and <slug> are ignored by Cyrus
+
+ if (!empty($this->comment)) {
+ $reply .= '<d:comment>' . htmlspecialchars($this->comment, ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:invite-reply xmlns:d="DAV:">' . $reply . '</d:invite-reply>';
+ }
+}
diff --git a/src/app/Backends/DAV/Notification.php b/src/app/Backends/DAV/Notification.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Notification.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Notification
+{
+ public const NOTIFICATION_SHARE_INVITE = 'share-invite-notification';
+ public const NOTIFICATION_SHARE_REPLY = 'share-reply-notification';
+
+ public const INVITE_NORESPONSE = 'invite-noresponse';
+ public const INVITE_ACCEPTED = 'invite-accepted';
+ public const INVITE_DECLINED = 'invite-declined';
+ public const INVITE_INVALID = 'invite-invalid';
+ public const INVITE_DELETED = 'invite-deleted';
+
+ public const INVITE_STATES = [
+ self::INVITE_NORESPONSE,
+ self::INVITE_ACCEPTED,
+ self::INVITE_DECLINED,
+ self::INVITE_INVALID,
+ self::INVITE_DELETED,
+ ];
+
+ /** @var ?string Notification (invitation) share-access property */
+ public $access;
+
+ /** @var ?string Notification location */
+ public $href;
+
+ /** @var ?string Notification type */
+ public $type;
+
+ /** @var ?string Notification (invitation) status */
+ public $status;
+
+ /** @var ?string Notification (invitation) principal (organizer) */
+ public $principal;
+
+
+ /**
+ * Create Notification object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ * @param string $href Notification location
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element, string $href)
+ {
+ $notification = new self();
+ $notification->href = $href;
+
+ if ($type = $element->getElementsByTagName('notificationtype')->item(0)) {
+ if ($type->firstChild) {
+ $notification->type = $type->firstChild->localName;
+ }
+ }
+
+ if ($access = $element->getElementsByTagName('access')->item(0)) {
+ if ($access->firstChild) {
+ $notification->access = $access->firstChild->localName; // 'read' or 'read-write'
+ }
+ }
+
+ foreach (self::INVITE_STATES as $name) {
+ if ($node = $element->getElementsByTagName($name)->item(0)) {
+ $notification->status = $node->localName;
+ }
+ }
+
+ if ($organizer = $element->getElementsByTagName('organizer')->item(0)) {
+ if ($href = $organizer->getElementsByTagName('href')->item(0)) {
+ $notification->principal = $href->nodeValue;
+ }
+ // There should be also 'displayname', but Cyrus uses 'common-name',
+ // we'll ignore it for now anyway.
+ } elseif ($principal = $element->getElementsByTagName('principal')->item(0)) {
+ if ($href = $principal->getElementsByTagName('href')->item(0)) {
+ $notification->principal = $href->nodeValue;
+ }
+ // There should be also 'displayname', but Cyrus uses 'common-name',
+ // we'll ignore it for now anyway.
+ }
+
+ return $notification;
+ }
+
+ /**
+ * Get XML string for PROPFIND query on a notification
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ // Note: With <d:allprop/> notificationtype is not returned, but it's essential
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:">'
+ . '<d:prop>'
+ . '<d:notificationtype/>'
+ . '</d:prop>'
+ . '</d:propfind>';
+ }
+}
diff --git a/src/app/Backends/DAV/Search.php b/src/app/Backends/DAV/Search.php
--- a/src/app/Backends/DAV/Search.php
+++ b/src/app/Backends/DAV/Search.php
@@ -12,17 +12,23 @@
public $dataProperties = [];
+ public $hrefs = [];
+
public $properties = [];
public $withContent = false;
public $filters = [];
- public function __construct($component, $withContent = false, $filters = [])
+ /** @var bool Is it a multiget report or a query? */
+ public $is_report = false;
+
+ public function __construct($component, $withContent = false, $filters = [], $is_report = false)
{
$this->component = $component;
$this->withContent = $withContent;
$this->filters = $filters;
+ $this->is_report = $is_report;
}
/**
@@ -37,6 +43,11 @@
'xmlns:c="' . DAV::NAMESPACES[$this->component] . '"',
]);
+ $hrefs = '';
+ foreach ($this->hrefs as $href) {
+ $hrefs .= '<d:href>' . $href . '</d:href>';
+ }
+
// Return properties
$props = [];
foreach ($this->properties as $prop) {
@@ -71,13 +82,17 @@
// Search filter
$filters = $this->filters;
if ($this->component == DAV::TYPE_VCARD) {
- $query = 'addressbook-query';
+ $query = $this->is_report ? 'addressbook-multiget' : 'addressbook-query';
} else {
- $query = 'calendar-query';
+ $query = $this->is_report ? 'calendar-multiget' : 'calendar-query';
array_unshift($filters, new SearchCompFilter('VCALENDAR', [new SearchCompFilter($this->component)]));
}
- $filter = new SearchFilter($filters);
+ if (!$this->is_report) {
+ $filter = new SearchFilter($filters);
+ } else {
+ $filter = '';
+ }
if (empty($props)) {
$props = '<d:allprop/>';
@@ -86,6 +101,6 @@
}
return '<?xml version="1.0" encoding="utf-8"?>'
- . "<c:{$query} {$ns}>" . $props . $filter . "</c:{$query}>";
+ . "<c:{$query} {$ns}>" . $hrefs . $props . $filter . "</c:{$query}>";
}
}
diff --git a/src/app/Backends/DAV/ShareResource.php b/src/app/Backends/DAV/ShareResource.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/ShareResource.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class ShareResource
+{
+ public const ACCESS_NONE = 'no-access';
+ public const ACCESS_READ = 'read';
+ public const ACCESS_READ_WRITE = 'read-write';
+
+ /** @var string Object content type (of the string representation) */
+ public $contentType = 'application/davsharing+xml; charset=utf-8';
+
+ /** @var ?string Resource (folder) location */
+ public $href;
+
+ /** @var ?array Resource sharees list */
+ public $sharees;
+
+
+ /**
+ * Create Share Resource object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ *
+ * @return Notification
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ throw new \Exception("Not implemented");
+ }
+
+ /**
+ * Convert a share-resource into an XML string to use in a request
+ */
+ public function __toString(): string
+ {
+ // TODO: Sharee should be an object not array
+
+ $props = '';
+ foreach ($this->sharees as $href => $sharee) {
+ if (!is_array($sharee)) {
+ $sharee = ['access' => $sharee];
+ }
+
+ $props .= '<d:sharee>'
+ . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
+ . '<d:share-access><d:' . ($sharee['access'] ?? self::ACCESS_NONE) . '/></d:share-access>';
+
+ if (isset($sharee['comment']) && strlen($sharee['comment'])) {
+ $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
+ $props .= '<d:prop><d:displayname>'
+ . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
+ . '</d:displayname></d:prop>';
+ }
+
+ $props .= '</d:sharee>';
+ }
+
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+ }
+}
diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -17,28 +17,6 @@
'full' => 'lrswipkxtecdn',
];
- /**
- * Delete a group.
- *
- * @param \App\Group $group Group
- *
- * @return bool True if a group was deleted successfully, False otherwise
- * @throws \Exception
- */
- public static function deleteGroup(Group $group): bool
- {
- $domainName = explode('@', $group->email, 2)[1];
-
- // Cleanup ACL
- // FIXME: Since all groups in Kolab4 have email address,
- // should we consider using it in ACL instead of the name?
- // Also we need to decide what to do and configure IMAP appropriately,
- // right now groups in ACL does not work for me at all.
- // Commented out in favor of a nightly cleanup job, for performance reasons
- // \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName);
-
- return true;
- }
/**
* Create a mailbox.
@@ -120,6 +98,29 @@
}
}
+ /**
+ * Delete a group.
+ *
+ * @param \App\Group $group Group
+ *
+ * @return bool True if a group was deleted successfully, False otherwise
+ * @throws \Exception
+ */
+ public static function deleteGroup(Group $group): bool
+ {
+ $domainName = explode('@', $group->email, 2)[1];
+
+ // Cleanup ACL
+ // FIXME: Since all groups in Kolab4 have email address,
+ // should we consider using it in ACL instead of the name?
+ // Also we need to decide what to do and configure IMAP appropriately,
+ // right now groups in ACL does not work for me at all.
+ // Commented out in favor of a nightly cleanup job, for performance reasons
+ // \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName);
+
+ return true;
+ }
+
/**
* Delete a mailbox
*
@@ -489,6 +490,131 @@
return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8');
}
+ /**
+ * Share default folders.
+ *
+ * @param User $user Folders' owner
+ * @param User $to Delegated user
+ * @param array $acl ACL Permissions per folder type
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function shareDefaultFolders(User $user, User $to, array $acl): bool
+ {
+ $folders = [];
+
+ if (!empty($acl['mail'])) {
+ $folders['INBOX'] = $acl['mail'];
+ }
+
+ foreach (\config('services.imap.default_folders') as $folder_name => $props) {
+ [$type, ] = explode('.', $props['metadata']['/private/vendor/kolab/folder-type'] ?? 'mail');
+ if (!empty($acl[$type])) {
+ $folders[$folder_name] = $acl[$type];
+ }
+ }
+
+ if (empty($folders)) {
+ return true;
+ }
+
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+
+ // Share folders with the delegatee
+ foreach ($folders as $folder => $rights) {
+ $mailbox = self::toUTF7($folder);
+ $rights = self::aclToImap(["{$to->email},{$rights}"])[$to->email];
+ if (!$imap->setACL($mailbox, $to->email, $rights)) {
+ \Log::error("Failed to share {$mailbox} with {$to->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+
+ $imap->closeConnection();
+
+ // Subscribe folders for the delegatee
+ $imap = self::initIMAP($config, $to->email);
+ [$local, ] = explode('@', $user->email);
+
+ foreach ($folders as $folder => $rights) {
+ // Note: This code assumes that "Other Users/" is the namespace prefix
+ // and that user is indicated by the email address's local part.
+ // It may not be sufficient if we ever wanted to do cross-domain sharing.
+ $mailbox = $folder == 'INBOX'
+ ? "Other Users/{$local}"
+ : self::toUTF7("Other Users/{$local}/{$folder}");
+
+ if (!$imap->subscribe($mailbox)) {
+ \Log::error("Failed to subscribe {$mailbox} for {$to->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
+ /**
+ * Unshare folders.
+ *
+ * @param User $user Folders' owner
+ * @param string $email Delegated user
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function unshareFolders(User $user, string $email): bool
+ {
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+
+ // FIXME: should we unshare all or only default folders (ones that we auto-share on delegation)?
+ foreach ($imap->listMailboxes('', '*') as $mailbox) {
+ if (!str_starts_with($mailbox, 'Other Users/')) {
+ if (!$imap->deleteACL($mailbox, $email)) {
+ \Log::error("Failed to unshare {$mailbox} with {$email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
+ /**
+ * Unsubscribe folders shared by other users.
+ *
+ * @param User $user Account owner
+ * @param string $email Other user email address
+ *
+ * @return bool True on success, False otherwise
+ */
+ public static function unsubscribeSharedFolders(User $user, string $email): bool
+ {
+ $config = self::getConfig();
+ $imap = self::initIMAP($config, $user->email);
+ [$local, ] = explode('@', $email);
+
+ // FIXME: should we unsubscribe all or only default folders (ones that we auto-subscribe on delegation)?
+ $root = "Other Users/{$local}";
+ foreach ($imap->listSubscribed($root, '*') as $mailbox) {
+ if ($mailbox == $root || str_starts_with($mailbox, $root . '/')) {
+ if (!$imap->unsubscribe($mailbox)) {
+ \Log::error("Failed to unsubscribe {$mailbox} by {$user->email}");
+ $imap->closeConnection();
+ return false;
+ }
+ }
+ }
+
+ $imap->closeConnection();
+ return true;
+ }
+
/**
* Check if an account is set up
*
diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php
--- a/src/app/Backends/Roundcube.php
+++ b/src/app/Backends/Roundcube.php
@@ -2,6 +2,7 @@
namespace App\Backends;
+use App\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
@@ -31,6 +32,48 @@
return DB::connection('roundcube');
}
+ /**
+ * Create delegator's identities for the delegatee
+ *
+ * @param User $delegatee The delegatee
+ * @param User $delegator The delegator
+ */
+ public static function createDelegatedIdentities(User $delegatee, User $delegator): void
+ {
+ $delegatee_id = self::userId($delegatee->email);
+ $delegator_id = self::userId($delegator->email, false);
+ $delegator_name = $delegator->name();
+ $org_name = $delegator->getSetting('organization');
+
+ $db = self::dbh();
+
+ if ($delegator_id) {
+ // Get signature/name from the delegator's default identity
+ $identity = $db->table(self::IDENTITIES_TABLE)->where('user_id', $delegator_id)
+ ->where('email', $delegator->email)
+ ->orderBy('standard', 'desc')
+ ->first();
+
+ if ($identity) {
+ if ($identity->name) {
+ $delegator_name = $identity->name;
+ }
+ if ($identity->organization) {
+ $org_name = $identity->organization;
+ }
+ }
+ }
+
+ // Create the identity
+ $db->table(self::IDENTITIES_TABLE)->insert([
+ 'user_id' => $delegatee_id,
+ 'email' => $delegator->email,
+ 'name' => (string) $delegator_name,
+ 'organization' => (string) $org_name,
+ 'changed' => now()->toDateTimeString(),
+ ]);
+ }
+
/**
* Remove all files from the Enigma filestore.
*
@@ -220,6 +263,33 @@
return true;
}
+ /**
+ * Validate user identities, remove these these that user no longer has access to.
+ *
+ * @param User $user User
+ */
+ public static function resetIdentities(User $user): void
+ {
+ $user_id = self::userId($user->email, false);
+
+ if (!$user_id) {
+ return;
+ }
+
+ // Collect email addresses
+ $users = $user->delegators()->pluck('email', 'user_id')->all();
+ $users[$user->id] = $user->email;
+ $aliases = \App\UserAlias::whereIn('user_id', array_keys($users))->pluck('alias')->all();
+ $all_addresses = array_merge(array_values($users), $aliases);
+
+ // Delete excessive identities
+ $db = self::dbh();
+ $db->table(self::IDENTITIES_TABLE)
+ ->where('user_id', $user_id)
+ ->whereNotIn('email', $all_addresses)
+ ->delete();
+ }
+
/**
* Find the Roundcube user identifier for the specified user.
*
@@ -253,7 +323,7 @@
'user_id'
);
- $username = \App\User::where('email', $email)->first()->name();
+ $username = User::where('email', $email)->first()->name();
$db->table(self::IDENTITIES_TABLE)->insert([
'user_id' => $user_id,
diff --git a/src/app/DataMigrator/Driver/DAV.php b/src/app/DataMigrator/Driver/DAV.php
--- a/src/app/DataMigrator/Driver/DAV.php
+++ b/src/app/DataMigrator/Driver/DAV.php
@@ -6,6 +6,7 @@
use App\Backends\DAV\Opaque as DAVOpaque;
use App\Backends\DAV\Folder as DAVFolder;
use App\Backends\DAV\Search as DAVSearch;
+use App\Backends\DAV\ShareResource as DAVShareResource;
use App\DataMigrator\Account;
use App\DataMigrator\Engine;
use App\DataMigrator\Interface\Folder;
@@ -175,15 +176,19 @@
foreach (User::whereIn('email', $emails)->pluck('email') as $email) {
$rights = $folder->acl[$email];
+ $principal = $this->client->principalLocation($email);
if (in_array('w', $rights) || in_array('a', $rights)) {
- $acl[$email] = DAVClient::SHARING_READ_WRITE;
+ $acl[$principal] = DAVShareResource::ACCESS_READ_WRITE;
} elseif (in_array('r', $rights)) {
- $acl[$email] = DAVClient::SHARING_READ;
+ $acl[$principal] = DAVShareResource::ACCESS_READ;
}
}
if (!empty($acl)) {
- if ($this->client->shareResource($href, $acl) === false) {
+ $share_resource = new DAVShareResource();
+ $share_resource->href = $href;
+ $share_resource->sharees = $acl;
+ if ($this->client->shareResource($share_resource) === false) {
\Log::warning("Failed to set sharees on the folder: {$href}");
}
}
diff --git a/src/app/Delegation.php b/src/app/Delegation.php
new file mode 100644
--- /dev/null
+++ b/src/app/Delegation.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace App;
+
+use App\Traits\StatusPropertyTrait;
+use Illuminate\Database\Eloquent\Relations\Pivot;
+
+/**
+ * Definition of Delegation (user to user relation).
+ *
+ * @property int $delegatee_id
+ * @property ?array $options
+ * @property int $status
+ * @property int $user_id
+ */
+class Delegation extends Pivot
+{
+ use StatusPropertyTrait;
+
+ public const STATUS_ACTIVE = 1 << 1;
+
+ /** @var int The allowed states for this object used in StatusPropertyTrait */
+ private int $allowed_states = self::STATUS_ACTIVE;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'status' => 'integer',
+ 'options' => 'array',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'user_id',
+ 'delegatee_id',
+ 'options',
+ ];
+
+ /** @var list<string> The attributes that can be null */
+ protected $nullable = [
+ 'options',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'delegations';
+
+ /** @var bool Enable primary key autoincrement (required here for Pivots) */
+ public $incrementing = true;
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+
+ /**
+ * The delegator user
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<User, $this>
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ /**
+ * The delegatee user
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<User, $this>
+ */
+ public function delegatee()
+ {
+ return $this->belongsTo(User::class, 'delegatee_id');
+ }
+
+ /**
+ * Validate delegation option value
+ *
+ * @param string $name Option name
+ * @param mixed $value Option valie
+ */
+ public static function validateOption($name, $value): bool
+ {
+ return in_array($name, ['mail', 'contact', 'event', 'task'])
+ && in_array($value, ['read-only', 'read-write']);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -6,6 +6,7 @@
use App\Policy\Greylist;
use App\Policy\Mailfilter;
use App\Policy\RateLimit;
+use App\Policy\SmtpAccess;
use App\Policy\SPF;
use Illuminate\Http\Request;
@@ -58,4 +59,16 @@
return $response->jsonResponse();
}
+
+ /*
+ * Validate sender/recipients in an SMTP submission request.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function submission()
+ {
+ $response = SmtpAccess::submission(\request()->input());
+
+ return $response->jsonResponse();
+ }
}
diff --git a/src/app/Http/Controllers/API/V4/User/DelegationTrait.php b/src/app/Http/Controllers/API/V4/User/DelegationTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/User/DelegationTrait.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\User;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+trait DelegationTrait
+{
+ /**
+ * Listing of delegations.
+ *
+ * @param string $id The user to get delegatees for
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function delegations($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canRead($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $result = $user->delegatees()->orderBy('email')->get()
+ ->map(function (User $user) {
+ return [
+ 'email' => $user->email,
+ 'options' => $user->delegation->options ?? [],
+ ];
+ });
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => false, // TODO
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Get a delegation info for the current user (for use by a webmail plugin)
+ *
+ * @param string $id The user to get delegators for
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function delegators($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user) || $user->role) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $delegators = $user->delegators()->orderBy('email')->get()
+ ->map(function (User $user) {
+ return [
+ 'email' => $user->email,
+ 'aliases' => $user->aliases()->pluck('alias'),
+ 'name' => $user->name(),
+ ];
+ });
+
+ return response()->json([
+ 'list' => $delegators,
+ 'count' => count($delegators),
+ 'hasMore' => false, // TODO
+ ]);
+ }
+
+ /**
+ * Delete a delegation.
+ *
+ * @param string $id User identifier
+ * @param string $email Delegatee's email address
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function deleteDelegation($id, $email)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $delegatee = User::where('email', $email)->first();
+
+ if (!$delegatee) {
+ return $this->errorResponse(404);
+ }
+
+ $delegation = Delegation::where('user_id', $user->id)->where('delegatee_id', $delegatee->id)->first();
+
+ if (!$delegation) {
+ return $this->errorResponse(404);
+ }
+
+ $delegation->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.delegation-delete-success'),
+ ]);
+ }
+
+ /**
+ * Create a new delegation.
+ *
+ * @param string $id User identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function createDelegation($id)
+ {
+ $user = User::find($id);
+
+ if (!$this->checkTenant($user)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+ if ($user->id != $current_user->id && !$current_user->canDelete($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $request = request();
+ $rules = [
+ 'email' => 'required|email',
+ 'options' => 'required|array',
+ ];
+
+ // Validate input
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $errors = [];
+ $options = [];
+ $request->email = strtolower($request->email);
+
+ if (
+ $request->email == $user->email
+ || !($delegatee = User::where('email', $request->email)->first())
+ || $delegatee->domainNamespace() != $user->domainNamespace()
+ || $user->delegatees()->where('delegatee_id', $delegatee->id)->exists()
+ ) {
+ $errors['email'] = [self::trans('validation.delegateeinvalid')];
+ }
+
+ foreach ($request->options as $key => $value) {
+ if (empty($value)) {
+ continue;
+ }
+
+ if (!Delegation::validateOption($key, $value)) {
+ $errors['options'] = [self::trans('validation.delegationoptioninvalid')];
+ break;
+ }
+
+ $options[$key] = $value;
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $delegation = new Delegation();
+ $delegation->user_id = $user->id;
+ $delegation->delegatee_id = $delegatee->id; // @phpstan-ignore-line
+ $delegation->options = $options;
+ $delegation->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.delegation-create-success'),
+ ]);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\API\V4;
+use App\Http\Controllers\API\V4\User\DelegationTrait;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\License;
@@ -17,6 +18,8 @@
class UsersController extends RelationController
{
+ use DelegationTrait;
+
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
@@ -240,6 +243,7 @@
$result = [
'skus' => $skus,
'enableBeta' => $hasBeta,
+ 'enableDelegation' => \config('app.with_delegation'),
'enableDomains' => $isController && ($hasCustomDomain || $plan?->hasDomain()),
'enableDistlists' => $isController && $hasCustomDomain && \config('app.with_distlists'),
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
diff --git a/src/app/Jobs/User/Delegation/CreateJob.php b/src/app/Jobs/User/Delegation/CreateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/User/Delegation/CreateJob.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\Jobs\UserJob;
+use App\Support\Facades\DAV;
+use App\Support\Facades\IMAP;
+use App\Support\Facades\Roundcube;
+
+class CreateJob extends UserJob
+{
+ /** @var int $delegationId Delegation identifier */
+ protected $delegationId;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $delegationId The ID for the delegation to create.
+ */
+ public function __construct(int $delegationId)
+ {
+ $this->delegationId = $delegationId;
+
+ $delegation = Delegation::find($delegationId);
+
+ if ($delegation->user) {
+ $this->userId = $delegation->user->id;
+ $this->userEmail = $delegation->user->email;
+ }
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ * @throws \Exception
+ */
+ public function handle()
+ {
+ $this->logJobStart("{$this->delegationId} ({$this->userEmail})");
+
+ $delegation = Delegation::find($this->delegationId);
+
+ if (!$delegation || $delegation->isActive()) {
+ return;
+ }
+
+ $user = $delegation->user;
+ $delegatee = $delegation->delegatee;
+
+ if (!$user || !$delegatee || $user->role || $delegatee->role || $user->trashed() || $delegatee->trashed()) {
+ return;
+ }
+
+ /*
+ if (!$user->isImapReady() || !$delegatee->isImapReady()) {
+ $this->release(60);
+ return;
+ }
+ */
+
+ // Create identities in Roundcube
+ if (\config('database.connections.roundcube')) {
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+ }
+
+ // Share IMAP and DAV folders
+ if (!IMAP::shareDefaultFolders($user, $delegatee, (array) $delegation->options)) {
+ throw new \Exception("Failed to set IMAP delegation for user {$this->userEmail}.");
+ }
+
+ DAV::shareDefaultFolders($user, $delegatee, (array) $delegation->options);
+
+ $delegation->status |= Delegation::STATUS_ACTIVE;
+ $delegation->save();
+ }
+}
diff --git a/src/app/Jobs/User/Delegation/DeleteJob.php b/src/app/Jobs/User/Delegation/DeleteJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/User/Delegation/DeleteJob.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\Jobs\CommonJob;
+use App\Support\Facades\DAV;
+use App\Support\Facades\IMAP;
+use App\Support\Facades\Roundcube;
+use App\User;
+
+class DeleteJob extends CommonJob
+{
+ /** @var string Delegator's email address */
+ protected $delegatorEmail;
+
+ /** @var string Delegatee's email address */
+ protected $delegateeEmail;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param string $delegatorEmail Delegator's email address.
+ * @param string $delegateeEmail Delegatee's email address.
+ */
+ public function __construct(string $delegatorEmail, string $delegateeEmail)
+ {
+ // Note: We're using email not id because the user may not exists anymore
+ $this->delegatorEmail = $delegatorEmail;
+ $this->delegateeEmail = $delegateeEmail;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $this->logJobStart("{$this->delegatorEmail}/{$this->delegateeEmail}");
+
+ $this->delegationCleanup($this->delegatorEmail, $this->delegateeEmail);
+ }
+
+ /**
+ * Cleanup delegation relation between two users
+ */
+ public static function delegationCleanup(string $delegator_email, string $delegatee_email)
+ {
+ $delegator = User::where('email', $delegator_email)->withTrashed()->first();
+ $delegatee = User::where('email', $delegatee_email)->withTrashed()->first();
+
+ // Make sure that the same delegation wasn't re-created in meantime
+ if ($delegator && $delegatee && $delegator->delegatees()->where('delegatee_id', $delegatee->id)->exists()) {
+ return;
+ }
+
+ // Remove identities
+ if ($delegatee && !$delegatee->isDeleted() && \config('database.connections.roundcube')) {
+ Roundcube::resetIdentities($delegatee);
+ }
+
+ // Unsubscribe folders shared by the delegator
+ if ($delegatee && $delegatee->isImapReady()) {
+ if (!IMAP::unsubscribeSharedFolders($delegatee, $delegator_email)) {
+ throw new \Exception("Failed to unsubscribe IMAP folders for user {$delegatee_email}.");
+ }
+
+ DAV::unsubscribeSharedFolders($delegatee, $delegator_email);
+ }
+
+ // Remove folder permissions for the delegatee
+ if ($delegator && $delegator->isImapReady()) {
+ if (!IMAP::unshareFolders($delegator, $delegatee_email)) {
+ throw new \Exception("Failed to unshare IMAP folders for user {$delegator_email}.");
+ }
+
+ DAV::unshareFolders($delegator, $delegatee_email);
+ }
+ }
+}
diff --git a/src/app/Observers/DelegationObserver.php b/src/app/Observers/DelegationObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/DelegationObserver.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Observers;
+
+use App\Delegation;
+
+class DelegationObserver
+{
+ /**
+ * Handle the delegation "created" event.
+ *
+ * @param Delegation $delegation The delegation
+ */
+ public function created(Delegation $delegation): void
+ {
+ \App\Jobs\User\Delegation\CreateJob::dispatch($delegation->id);
+ }
+
+ /**
+ * Handle the delegation "deleted" event.
+ *
+ * @param Delegation $delegation The delegation
+ */
+ public function deleted(Delegation $delegation): void
+ {
+ \App\Jobs\User\Delegation\DeleteJob::dispatch($delegation->user->email, $delegation->delegatee->email);
+ }
+}
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -8,7 +8,7 @@
class GroupObserver
{
/**
- * Handle the group "created" event.
+ * Handle the group "creating" event.
*
* @param \App\Group $group The group
*
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -2,6 +2,7 @@
namespace App\Observers;
+use App\Delegation;
use App\Group;
use App\User;
use App\Wallet;
@@ -109,6 +110,24 @@
});
}
+ // Remove delegation relations
+ $ids = Delegation::where('user_id', $user->id)->orWhere('delegatee_id', $user->id)->get()
+ ->map(function ($delegation) use ($user) {
+ $delegator = $delegation->user_id == $user->id
+ ? $user : $delegation->user()->withTrashed()->first();
+ $delegatee = $delegation->delegatee_id == $user->id
+ ? $user : $delegation->delegatee()->withTrashed()->first();
+
+ \App\Jobs\User\Delegation\DeleteJob::dispatch($delegator->email, $delegatee->email);
+
+ return $delegation->id;
+ })
+ ->all();
+
+ if (!empty($ids)) {
+ Delegation::whereIn('id', $ids)->delete();
+ }
+
// TODO: Remove Permission records for the user
// TODO: Remove file permissions for the user
}
diff --git a/src/app/Policy/Response.php b/src/app/Policy/Response.php
--- a/src/app/Policy/Response.php
+++ b/src/app/Policy/Response.php
@@ -9,6 +9,7 @@
public const ACTION_DEFER_IF_PERMIT = 'DEFER_IF_PERMIT';
public const ACTION_DUNNO = 'DUNNO';
public const ACTION_HOLD = 'HOLD';
+ public const ACTION_PERMIT = 'PERMIT';
public const ACTION_REJECT = 'REJECT';
/** @var string Postfix action */
diff --git a/src/app/Policy/SmtpAccess.php b/src/app/Policy/SmtpAccess.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/SmtpAccess.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Policy;
+
+use App\User;
+use App\UserAlias;
+
+class SmtpAccess
+{
+ /**
+ * Handle SMTP submission request
+ *
+ * @param array $data Input data
+ */
+ public static function submission($data): Response
+ {
+ // TODO: The old SMTP access policy had an option ('empty_sender_hosts') to allow
+ // sending mail with no sender from configured networks.
+
+ list($local, $domain) = \App\Utils::normalizeAddress($data['sender'], true);
+
+ if (empty($local) || empty($domain)) {
+ return new Response(Response::ACTION_REJECT, 'Invalid sender', 403);
+ }
+
+ $sender = $local . '@' . $domain;
+
+ list($local, $domain) = \App\Utils::normalizeAddress($data['user'], true);
+
+ if (empty($local) || empty($domain)) {
+ return new Response(Response::ACTION_REJECT, 'Invalid user', 403);
+ }
+
+ $sasl_user = $local . '@' . $domain;
+
+ $user = \App\User::where('email', $sasl_user)->first();
+
+ if (!$user) {
+ return new Response(Response::ACTION_REJECT, "Could not find user {$data['user']}", 403);
+ }
+
+ if (!SmtpAccess::verifySender($user, $sender)) {
+ $reason = "{$sasl_user} is unauthorized to send mail as {$sender}";
+ return new Response(Response::ACTION_REJECT, $reason, 403);
+ }
+
+ // TODO: Prepending Sender/X-Sender/X-Authenticated-As headers?
+ // TODO: Recipient policies here?
+ // TODO: Check rate limit here?
+
+ return new Response(Response::ACTION_PERMIT);
+ }
+
+ /**
+ * Verify whether a user is allowed to send using the envelope sender address.
+ *
+ * @param User $user Authenticated user
+ * @param string $email Email address
+ */
+ public static function verifySender(User $user, string $email): bool
+ {
+ if ($user->isSuspended() || strpos($email, '@') === false) {
+ return false;
+ }
+
+ // TODO: Make sure the domain is not suspended
+ // TODO: Email might belong to a group (distlists), check group's sender_policy
+ // TODO: Email might be a shared folder (or it's alias)?
+
+ $email = \strtolower($email);
+
+ if ($user->email == $email) {
+ return true;
+ }
+
+ // Is it one of user's aliases?
+ $alias = $user->aliases()->where('alias', $email)->first();
+
+ if ($alias) {
+ return true;
+ }
+
+ // Delegation
+ if (\config('app.with_delegation')) {
+ // Is it another user's email?
+ $other_users = User::where('email', $email)->pluck('id')->all();
+
+ if (!count($other_users)) {
+ // Is it another user's alias?
+ $other_users = UserAlias::where('alias', $email)->pluck('user_id')->all();
+ }
+
+ if (count($other_users)) {
+ // Is the user a delegatee of that other user? Is he suspended?
+ $is_delegate = $user->delegators()->whereIn('user_id', $other_users)
+ ->whereNot('users.status', '&', User::STATUS_SUSPENDED)
+ ->exists();
+
+ if ($is_delegate) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -64,6 +64,7 @@
*/
public function boot(): void
{
+ \App\Delegation::observe(\App\Observers\DelegationObserver::class);
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\EventLog::observe(\App\Observers\EventLogObserver::class);
diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php
--- a/src/app/Traits/EmailPropertyTrait.php
+++ b/src/app/Traits/EmailPropertyTrait.php
@@ -2,6 +2,8 @@
namespace App\Traits;
+use App\Domain;
+
trait EmailPropertyTrait
{
/** @var ?string Domain name for the to-be-created object */
@@ -42,19 +44,34 @@
/**
* Returns the object's domain (including soft-deleted).
*
- * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist
+ * @return ?Domain The domain to which the object belongs to, NULL if it does not exist
*/
- public function domain(): ?\App\Domain
+ public function domain(): ?Domain
+ {
+ if ($domain = $this->domainNamespace()) {
+ return Domain::withTrashed()->where('namespace', $domain)->first();
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the object's domain namespace.
+ *
+ * @return ?string The domain to which the object belongs to if it has email property is set
+ */
+ public function domainNamespace(): ?string
{
if (empty($this->email) && isset($this->domainName)) {
- $domainName = $this->domainName;
- } elseif (strpos($this->email, '@')) {
- list($local, $domainName) = explode('@', $this->email);
- } else {
- return null;
+ return $this->domainName;
+ }
+
+ if (strpos($this->email, '@')) {
+ [$local, $domain] = explode('@', $this->email);
+ return $domain;
}
- return \App\Domain::withTrashed()->where('namespace', $domainName)->first();
+ return null;
}
/**
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -299,6 +299,31 @@
$this->save();
}
+ /**
+ * Users that this user is delegatee of.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<User, $this, Delegation>
+ */
+ public function delegators()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'delegatee_id', 'user_id')
+ ->as('delegation')
+ ->using(Delegation::class);
+ }
+
+ /**
+ * Users that are delegatees of this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<User, $this, Delegation>
+ */
+ public function delegatees()
+ {
+ return $this->belongsToMany(User::class, 'delegations', 'user_id', 'delegatee_id')
+ ->as('delegation')
+ ->using(Delegation::class)
+ ->withPivot('options');
+ }
+
/**
* List the domains to which this user is entitled.
*
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -251,6 +251,7 @@
'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true),
'with_wallet' => (bool) env('APP_WITH_WALLET', true),
+ 'with_delegation' => (bool) env('APP_WITH_DELEGATION', true),
'with_distlists' => (bool) env('APP_WITH_DISTLISTS', true),
'with_shared_folders' => (bool) env('APP_WITH_SHARED_FOLDERS', true),
diff --git a/src/database/migrations/2025_03_28_100000_create_delegations_table.php b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_03_28_100000_create_delegations_table.php
@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'delegations',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->bigInteger('delegatee_id');
+ $table->text('options')->nullable(); // json
+ $table->smallInteger('status')->default(0);
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->unique(['user_id', 'delegatee_id']);
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ $table->foreign('delegatee_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('delegations');
+ }
+};
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -9,6 +9,7 @@
- '#Access to an undefined property Sabre\\VObject\\(Component|Document)::#'
- '#Call to an undefined method Tests\\Browser::#'
- '#Call to an undefined method garethp\\ews\\API\\Type::#'
+ - '#Method App\\User::delegat.*\(\) should return.*#'
level: 5
parallel:
processTimeout: 300.0
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -72,6 +72,9 @@
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
+ 'delegation-create-success' => 'Delegation created successfully.',
+ 'delegation-delete-success' => 'Delegation deleted successfully.',
+
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -209,6 +209,7 @@
'lastname' => "Last Name",
'less' => "Less",
'name' => "Name",
+ 'mainopts' => "Main Options",
'months' => "months",
'more' => "More",
'none' => "none",
@@ -498,6 +499,16 @@
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
+ 'delegation' => "Delegation",
+ 'delegation-create' => "Add delegate",
+ 'delegation-mail' => "Mail",
+ 'delegation-event' => "Calendar",
+ 'delegation-task' => "Tasks",
+ 'delegation-contact' => "Contacts",
+ 'delegation-none' => "There are no delegates.",
+ 'delegation-desc' => "Delegates can send mail in your behalf and manage your mail/calendar/tasks/contacts according to the given permissions."
+ . " This includes e.g. inviting people to meetings and responding to such requests.",
+ 'delegation-perm' => "This delegate has the following permissions:",
'delete' => "Delete user",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -136,6 +136,8 @@
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
+ 'delegateeinvalid' => 'The specified email address is not a valid delegation target.',
+ 'delegationoptioninvalid' => 'The specified delegation options are invalid.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -116,6 +116,16 @@
& > button + button {
margin-left: .5em;
}
+
+ .accordion-header > & {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ top: 0;
+ right: 3.25rem;
+ height: 2.5rem;
+ z-index: 3;
+ }
}
// Various improvements for mobile
diff --git a/src/resources/themes/variables.scss b/src/resources/themes/variables.scss
--- a/src/resources/themes/variables.scss
+++ b/src/resources/themes/variables.scss
@@ -1,3 +1,10 @@
+/* Colors */
+
+$kolab-blue: #65c2ee;
+$light: #f6f5f3;
+
+$main-color: $kolab-blue;
+
/* Body */
$body-bg: #fff;
@@ -8,12 +15,12 @@
$font-size-base: 0.9rem;
$line-height-base: 1.5;
-/* Colors */
-
-$kolab-blue: #65c2ee;
-$light: #f6f5f3;
+/* Bootstrap components */
-$main-color: $kolab-blue;
+$accordion-padding-y: 1rem;
+$accordion-padding-x: 1rem;
+$accordion-button-padding-y: 0.7rem;
+$accordion-button-active-bg: $light;
/* App colors */
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -11,7 +11,7 @@
</btn>
</div>
<div class="card-text">
- <tabs class="mt-3" :tabs="tabs"></tabs>
+ <tabs class="mt-3" :tabs="tabs" ref="tabs"></tabs>
<div class="tab-content">
<div class="tab-pane active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
@@ -88,43 +88,57 @@
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
- <div v-if="isController" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
- <form @submit.prevent="submitSettings" class="card-body">
- <div class="row checkbox mb-3">
- <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
- <small id="greylisting-hint" class="text-muted">
- {{ $t('user.greylisting-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
- <label for="guam_enabled" class="col-sm-4 col-form-label">
- {{ $t('user.imapproxy') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
- <small id="guam-hint" class="text-muted">
- {{ $t('user.imapproxy-text') }}
- </small>
- </div>
- </div>
- <div v-if="$root.hasPermission('beta')" class="row mb-3">
- <label for="limit_geo" class="col-sm-4 col-form-label">
- {{ $t('user.geolimit') }}
- <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
- </label>
- <div class="col-sm-8 pt-2">
- <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
- <small id="geolimit-hint" class="text-muted">
- {{ $t('user.geolimit-text') }}
- </small>
- </div>
- </div>
- <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
- </form>
+ <div v-if="Object.keys(settingsSections).length > 0" class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <accordion class="mt-3" id="settings-all" :names="settingsSections" :buttons="settingsButtons">
+ <template #options v-if="settingsSections.options">
+ <form @submit.prevent="submitSettings">
+ <div class="row checkbox mb-3">
+ <label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
+ <small id="greylisting-hint" class="text-muted">
+ {{ $t('user.greylisting-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row checkbox mb-3">
+ <label for="guam_enabled" class="col-sm-4 col-form-label">
+ {{ $t('user.imapproxy') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <input type="checkbox" id="guam_enabled" name="guam_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.guam_enabled">
+ <small id="guam-hint" class="text-muted">
+ {{ $t('user.imapproxy-text') }}
+ </small>
+ </div>
+ </div>
+ <div v-if="$root.hasPermission('beta')" class="row mb-3">
+ <label for="limit_geo" class="col-sm-4 col-form-label">
+ {{ $t('user.geolimit') }}
+ <sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </label>
+ <div class="col-sm-8 pt-2">
+ <country-select id="limit_geo" v-model="user.config.limit_geo"></country-select>
+ <small id="geolimit-hint" class="text-muted">
+ {{ $t('user.geolimit-text') }}
+ </small>
+ </div>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </template>
+ <template #delegation v-if="settingsSections.delegation">
+ <list-table :list="delegations" :setup="delegationListSetup" class="mb-0">
+ <template #email="{ item }">
+ <svg-icon icon="user-tie"></svg-icon> <span>{{ item.email }}</span>
+ </template>
+ <template #buttons="{ item }">
+ <btn class="text-danger button-delete p-0 ms-1" @click="delegationDelete(item.email)" icon="trash-can" :title="$t('btn.delete')"></btn>
+ </template>
+ </list-table>
+ </template>
+ </accordion>
</div>
<div class="tab-pane" id="personal" role="tabpanel" aria-labelledby="tab-personal">
<form @submit.prevent="submitPersonalSettings" class="card-body">
@@ -191,12 +205,41 @@
</div>
<p v-else>{{ $t('user.delete-text') }}</p>
</modal-dialog>
+ <modal-dialog id="delegation-create" ref="delegationDialog" :buttons="['save']" @click="delegationCreate()" :title="$t('user.delegation-create')">
+ <form class="card-body" data-validation-prefix="delegation-">
+ <div class="row mb-3">
+ <label for="delegation-email" class="col-sm-4 col-form-label">{{ $t('form.user') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="delegation-email" v-model="delegatee" :placeholder="$t('form.email')">
+ </div>
+ </div>
+ <div class="row">
+ <label class="col-form-label">{{ $t('user.delegation-perm') }}</label>
+ </div>
+ <div class="row mb-2" v-for="(icon, type) in delegationTypes" :key="`delegation-${type}-row`">
+ <label for="delegation-" class="col-4 col-form-label">
+ <svg-icon :icon="icon" class="fs-3 me-2" style="width:1em"></svg-icon>
+ <span class="align-text-bottom">{{ $t(`user.delegation-${type}`) }}</span>
+ </label>
+ <div class="col-8">
+ <select type="text" class="form-select" :id="`delegation-${type}`">
+ <option value="" selected>- {{ $t('form.none') }} -</option>
+ <option value="read-only">{{ $t('form.acl-read-only') }}</option>
+ <option value="read-write">{{ $t('form.acl-read-write') }}</option>
+ </select>
+ </div>
+ </div>
+ <div class="row form-text"><span>{{ $t('user.delegation-desc') }}</span></div>
+ </form>
+ </modal-dialog>
</div>
</template>
<script>
+ import Accordion from '../Widgets/Accordion'
import CountrySelect from '../Widgets/CountrySelect'
import ListInput from '../Widgets/ListInput'
+ import { ListTable } from '../Widgets/ListTools'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
@@ -207,12 +250,19 @@
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
+ require('@fortawesome/free-solid-svg-icons/faCalendarCheck').definition,
+ require('@fortawesome/free-solid-svg-icons/faCalendarDays').definition,
+ require('@fortawesome/free-solid-svg-icons/faEnvelope').definition,
+ require('@fortawesome/free-solid-svg-icons/faUserTie').definition,
+ require('@fortawesome/free-solid-svg-icons/faUsers').definition,
)
export default {
components: {
+ Accordion,
CountrySelect,
ListInput,
+ ListTable,
ModalDialog,
PackageSelect,
PasswordInput,
@@ -222,11 +272,38 @@
data() {
return {
countries: window.config.countries,
+ delegatee: null,
+ delegations: null,
+ delegationListSetup: {
+ buttons: true,
+ columns: [
+ {
+ prop: 'email',
+ contentSlot: 'email'
+ },
+ ],
+ footLabel: 'user.delegation-none'
+ },
+ delegationTypes: {
+ mail: 'envelope',
+ event: 'calendar-days',
+ task: 'calendar-check',
+ contact: 'users'
+ },
isSelf: false,
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
+ settingsButtons: {
+ delegation: [
+ {
+ icon: 'user-tie',
+ label: this.$t('user.delegation-create'),
+ click: () => this.$refs.delegationDialog.show()
+ }
+ ],
+ },
supportEmail: window.config['app.support_email'],
status: {},
successRoute: { name: 'users' }
@@ -241,6 +318,16 @@
icon: 'trash-can'
}
},
+ settingsSections: function () {
+ let opts = {}
+ if (this.isController) {
+ opts.options = this.$t('form.mainopts')
+ }
+ if ((this.isController || this.isSelf) && this.$root.authInfo.statusInfo.enableDelegation) {
+ opts.delegation = this.$t('user.delegation')
+ }
+ return opts
+ },
isController: function () {
return this.$root.hasPermission('users')
},
@@ -254,7 +341,7 @@
return tabs
}
- if (this.isController) {
+ if (Object.keys(this.settingsSections).length > 0) {
tabs.push('form.settings')
}
@@ -291,6 +378,20 @@
},
mounted() {
$('#first_name').focus()
+
+ if (this.settingsSections.delegation) {
+ this.$refs.tabs.clickHandler('settings', () => {
+ if (this.delegations === null) {
+ this.delegationList()
+ }
+ })
+ }
+
+ this.$refs.delegationDialog.events({
+ show: (event) => {
+ this.delegatee = null
+ }
+ })
},
methods: {
passwordLinkCopy() {
@@ -418,6 +519,40 @@
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
+ delegationCreate() {
+ let post = {email: this.delegatee, options: {}}
+
+ $('#delegation-create select').each(function () {
+ post.options[this.id.split('-')[1]] = this.value;
+ });
+
+ axios.post('/api/v4/users/' + this.user_id + '/delegations', post)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$refs.delegationDialog.hide();
+ this.delegationList(true)
+ }
+ })
+ },
+ delegationDelete(email) {
+ axios.delete('/api/v4/users/' + this.user_id + '/delegations/' + email)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.delegationList(true)
+ }
+ })
+ },
+ delegationList(reset) {
+ if (reset) {
+ this.delegations = null
+ }
+ axios.get('/api/v4/users/' + this.user_id + '/delegations', { loader: '#delegation' })
+ .then(response => {
+ this.delegations = response.data.list
+ })
+ },
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
diff --git a/src/resources/vue/Widgets/Accordion.vue b/src/resources/vue/Widgets/Accordion.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/Accordion.vue
@@ -0,0 +1,44 @@
+<template>
+ <div class="accordion" :id="id">
+ <div v-for="(slot, slotName) in $slots" class="accordion-item position-relative" :key="slotName">
+ <h2 :id="slotName + '-header'" class="accordion-header">
+ <button type="button" data-bs-toggle="collapse" :data-bs-target="'#' + slotName"
+ :class="'accordion-button' + (isFirst(slotName) ? '' : ' collapsed')"
+ :aria-expanded="isFirst(slotName) ? 'true' : 'false'"
+ :aria-controls="slotName"
+ >
+ {{ names[slotName] }}
+ <sup v-if="beta.includes(slotName)" class="badge bg-primary">{{ $t('dashboard.beta') }}</sup>
+ </button>
+ <div class="buttons">
+ <btn v-for="(button, idx) in buttons[slotName]" :key="idx" :icon="button.icon" class="btn-sm btn-outline-secondary" @click="button.click()">
+ {{ button.label }}
+ </btn>
+ </div>
+ </h2>
+ <div :id="slotName" :class="'accordion-collapse collapse' + (isFirst(slotName) ? ' show' : '')" :data-bs-parent="'#' + id">
+ <div class="accordion-body">
+ <slot :name="slotName"></slot>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ beta: { type: Array, default: () => [] },
+ buttons: { type: Object, default: () => {} },
+ id: { type: String, default: 'accordion' },
+ names: { type: Object, default: () => {} },
+ },
+ methods: {
+ isFirst(slotName) {
+ for (let name in this.$slots) {
+ return name == slotName
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -156,6 +156,12 @@
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
+ if (\config('app.with_delegation')) {
+ Route::get('users/{id}/delegations', [API\V4\UsersController::class, 'delegations']);
+ Route::post('users/{id}/delegations', [API\V4\UsersController::class, 'createDelegation']);
+ Route::delete('users/{id}/delegations/{email}', [API\V4\UsersController::class, 'deleteDelegation']);
+ Route::get('users/{id}/delegators', [API\V4\UsersController::class, 'delegators']);
+ }
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
@@ -222,6 +228,7 @@
Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
+ Route::post('policy/submission', [API\V4\PolicyController::class, 'submission']);
Route::post('policy/mail/filter', [API\V4\PolicyController::class, 'mailfilter']);
}
);
diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php
--- a/src/tests/Browser/Pages/UserInfo.php
+++ b/src/tests/Browser/Pages/UserInfo.php
@@ -41,6 +41,10 @@
'@nav' => 'ul.nav-tabs',
'@packages' => '#user-packages',
'@settings' => '#settings',
+ '@setting-options' => '#options .accordion-body',
+ '@setting-options-head' => '#options-header',
+ '@setting-delegation' => '#delegation .accordion-body',
+ '@setting-delegation-head' => '#delegation-header',
'@general' => '#general',
'@personal' => '#personal',
'@skus' => '#user-skus',
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -2,6 +2,7 @@
namespace Tests\Browser;
+use App\Delegation;
use App\Discount;
use App\Entitlement;
use App\Sku;
@@ -72,8 +73,10 @@
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
- UserAlias::where('user_id', $john->id)
- ->where('alias', 'john.test@kolab.org')->delete();
+ $john->aliases()->where('alias', 'john.test@kolab.org')->delete();
+ $john->delegators()->each(function ($user) {
+ $user->delegation->delete();
+ });
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
@@ -329,7 +332,8 @@
*/
// Finally submit the form
- $browser->click('button[type=submit]')
+ $browser->scrollTo('button[type=submit]')->pause(500)
+ ->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$this->assertSame(1, $jack->verificationcodes()->where('active', true)->count());
@@ -420,7 +424,8 @@
->on(new UserInfo())
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
- ->with('@settings', function (Browser $browser) {
+ ->assertSeeIn('@setting-options-head', 'Main Options')
+ ->with('@setting-options', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->assertMissing('div.row:nth-child(2)') // guam and geo-lockin settings are hidden
->click('div.row:nth-child(1) input[type=checkbox]:checked')
@@ -437,7 +442,7 @@
$browser->refresh()
->on(new UserInfo())
->click('@nav #tab-settings')
- ->with('@settings', function (Browser $browser) use ($john) {
+ ->with('@setting-options', function (Browser $browser) use ($john) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->assertSeeIn('div.row:nth-child(2) label', 'IMAP proxy')
->assertNotChecked('div.row:nth-child(2) input')
@@ -855,4 +860,77 @@
});
});
}
+
+ /**
+ * Test delegation settings
+ */
+ public function testUserDelegation(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->delegatees()->each(function ($user) {
+ $user->delegation->delete();
+ });
+
+ $this->browse(function (Browser $browser) use ($jack) {
+ $browser->visit('/login')
+ ->on(new Home())
+ ->submitLogon($jack->email, 'simple123', true)
+ ->on(new Dashboard())
+ ->click('@links .link-settings')
+ ->on(new UserInfo())
+ ->click('@nav #tab-settings')
+ // Note: Jack should not see Main Options
+ ->assertMissing('@setting-options')
+ ->assertMissing('@setting-options-head')
+ ->assertSeeIn('@setting-delegation-head', 'Delegation')
+ // ->click('@settings .accordion-item:nth-child(2) .accordion-button')
+ ->whenAvailable('@setting-delegation', function (Browser $browser) {
+ $browser->assertSeeIn('table tfoot td', 'There are no delegates.')
+ ->assertMissing('table tbody tr');
+ })
+ ->assertSeeIn('@setting-delegation-head .buttons button', 'Add delegate')
+ ->click('@setting-delegation-head .buttons button')
+ ->with(new Dialog('#delegation-create'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Add delegate')
+ ->assertFocused('#delegation-email')
+ ->assertValue('#delegation-email', '')
+ ->assertSelected('#delegation-mail', '')
+ ->assertSelectHasOptions('#delegation-mail', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-event', '')
+ ->assertSelectHasOptions('#delegation-event', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-task', '')
+ ->assertSelectHasOptions('#delegation-task', ['', 'read-only', 'read-write'])
+ ->assertSelected('#delegation-contact', '')
+ ->assertSelectHasOptions('#delegation-contact', ['', 'read-only', 'read-write'])
+ ->assertVisible('.row.form-text')
+ ->type('#delegation-email', 'john@kolab.org')
+ ->select('#delegation-mail', 'read-only')
+ ->select('#delegation-contact', 'read-write')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Save')
+ ->click('@button-action');
+ })
+ ->waitUntilMissing('#delegation-create')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Delegation created successfully.');
+
+ // TODO: Test error handling
+ // TODO: Test acting as a wallet controller
+
+ $delegatee = $jack->delegatees()->first();
+ $this->assertSame('john@kolab.org', $delegatee->email);
+ $this->assertSame(['mail' => 'read-only', 'contact' => 'read-write'], $delegatee->delegation->options);
+
+ // Remove delegation
+ $browser->waitFor('@setting-delegation table tbody tr')
+ ->whenAvailable('@setting-delegation', function (Browser $browser) {
+ $browser->assertMissing('table tfoot td')
+ ->assertSeeIn('table tbody tr td:first-child', 'john@kolab.org')
+ ->click('table button.text-danger');
+ })
+ ->waitUntilMissing('@setting-delegation table tbody tr')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Delegation deleted successfully.');
+
+ $this->assertSame(0, $jack->delegatees()->count());
+ });
+ }
}
diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php
--- a/src/tests/Feature/Backends/DAVTest.php
+++ b/src/tests/Feature/Backends/DAVTest.php
@@ -10,6 +10,7 @@
class DAVTest extends TestCase
{
private $user;
+ private $user2;
/**
* {@inheritDoc}
@@ -31,26 +32,27 @@
if ($this->user) {
$this->deleteTestUser($this->user->email, true);
}
+ if ($this->user2) {
+ $this->deleteTestUser($this->user2->email);
+ }
parent::tearDown();
}
-
/**
* Test initializing default folders for a user.
*
* @group imap
* @group dav
*/
- public function testInitDefaultFolders(): void
+ public function testInitDefaultFolders(): array
{
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(['services.imap.default_folders' => null]);
- IMAP::createUser($user);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $user = $this->getTestUser(
+ "davtest-{$ts}@" . \config('app.domain'),
+ $props = ['password' => 'test-pass']
+ );
$dav_folders = [
[
@@ -72,7 +74,10 @@
],
];
+ // Create the IMAP mailbox, it is required otherwise DAV requests will fail
+ \config(['services.imap.default_folders' => null]);
\config(['services.dav.default_folders' => $dav_folders]);
+ IMAP::createUser($user);
DAV::initDefaultFolders($user);
$dav = DAV::getInstance($user->email, $props['password']);
@@ -100,5 +105,96 @@
$this->assertSame(['VTODO'], $folders[0]->components);
$this->assertSame(['collection', 'calendar'], $folders[0]->types);
$this->assertSame('Tasks-Test', $folders[0]->name);
+
+ return [$dav_folders, $user];
+ }
+
+ /**
+ * Test sharing/unsharing folders for a user (delegation).
+ *
+ * @depends testInitDefaultFolders
+ * @group imap
+ * @group dav
+ */
+ public function testShareAndUnshareFolders($args): void
+ {
+ Queue::fake();
+
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user2 = $user2 = $this->getTestUser(
+ "davtest2-{$ts}@" . \config('app.domain'),
+ $props = ['password' => 'test-pass']
+ );
+ $this->user = $user = $args[1];
+ $dav_folders = $args[0];
+
+ // Create the IMAP mailbox, it is required otherwise DAV requests will fail
+ \config(['services.imap.default_folders' => null]);
+ \config(['services.dav.default_folders' => $dav_folders]);
+ IMAP::createUser($user2);
+ DAV::initDefaultFolders($user2);
+
+ // Test delegation of calendar and addressbook folders only
+ DAV::shareDefaultFolders($user, $user2, ['event' => 'read-only', 'contact' => 'read-write']);
+
+ $dav = DAV::getInstance($user2->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(1, $folders);
+ $this->assertSame('read-write', $folders[0]->shareAccess);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(1, $folders);
+ $this->assertSame('read', $folders[0]->shareAccess);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VTODO),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ // Test unsubscribing from other user folders
+ DAV::unsubscribeSharedFolders($user2, $user->email);
+
+ $dav = DAV::getInstance($user2->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner === $user->email
+ ));
+ $this->assertCount(0, $folders);
+
+ // Test unsharing folders
+ DAV::unshareFolders($user, $user2->email);
+
+ $dav = DAV::getInstance($user->email, $props['password']);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VCARD),
+ fn ($folder) => $folder->owner != $user->email
+ || $folder->shareAccess != DAV\Folder::SHARE_ACCESS_NONE
+ || !empty($folder->invites)
+ ));
+ $this->assertCount(0, $folders);
+
+ $folders = array_values(array_filter(
+ $dav->listFolders(DAV::TYPE_VEVENT),
+ fn ($folder) => $folder->owner != $user->email
+ || $folder->shareAccess != DAV\Folder::SHARE_ACCESS_NONE
+ || !empty($folder->invites)
+ ));
+ $this->assertCount(0, $folders);
}
}
diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -3,7 +3,6 @@
namespace Tests\Feature\Backends;
use App\Backends\IMAP;
-use App\Backends\LDAP;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -11,6 +10,7 @@
{
private $imap;
private $user;
+ private $user2;
private $group;
private $resource;
private $folder;
@@ -40,6 +40,9 @@
if ($this->user) {
$this->deleteTestUser($this->user->email, true);
}
+ if ($this->user2) {
+ $this->deleteTestUser($this->user2->email);
+ }
if ($this->group) {
$this->deleteTestGroup($this->group->email, true);
}
@@ -57,20 +60,14 @@
* Test aclCleanup()
*
* @group imap
- * @group ldap
*/
public function testAclCleanup(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org', [], true);
- $this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org');
-
- // SETACL requires that the user/group exists in LDAP
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
- // LDAP::createGroup($group);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@kolab.org", [], true);
+ $this->group = $group = $this->getTestGroup("test-group-{$ts}@kolab.org");
// First, set some ACLs that we'll expect to be removed later
$imap = $this->getImap();
@@ -104,20 +101,14 @@
* Test aclCleanupDomain()
*
* @group imap
- * @group ldap
*/
public function testAclCleanupDomain(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org', [], true);
- $this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org');
-
- // SETACL requires that the user/group exists in LDAP
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
- // LDAP::createGroup($group);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@kolab.org", [], true);
+ $this->group = $group = $this->getTestGroup("test-group-{$ts}@kolab.org");
// First, set some ACLs that we'll expect to be removed later
$imap = $this->getImap();
@@ -159,21 +150,16 @@
* Test creating/updating/deleting an IMAP account
*
* @group imap
- * @group ldap
*/
public function testUsers(): void
{
Queue::fake();
- $this->user = $user = $this->getTestUser('test-' . time() . '@' . \config('app.domain'), []);
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@" . \config('app.domain'), []);
$storage = \App\Sku::withObjectTenantContext($user)->where('title', 'storage')->first();
$user->assignSku($storage, 1, $user->wallets->first());
- // User must be in ldap, so imap auth works
- if (\config('app.with_ldap')) {
- LDAP::createUser($user);
- }
-
$expectedQuota = [
'user/' . $user->email => [
'storage' => [
@@ -210,6 +196,76 @@
$this->assertFalse(IMAP::verifyAccount($user->email));
}
+ /**
+ * Test sharing and unsharing folders (for delegation)
+ *
+ * @group imap
+ */
+ public function testShareAndUnshareFolders(): void
+ {
+ Queue::fake();
+
+ $ts = str_replace('.', '', (string) microtime(true));
+ $this->user = $user = $this->getTestUser("test-{$ts}@" . \config('app.domain'), []);
+ $this->user2 = $user2 = $this->getTestUser("test2-{$ts}@" . \config('app.domain'), []);
+
+ // Create the mailbox
+ $result = IMAP::createUser($user);
+ $this->assertTrue($result);
+
+ $imap = $this->getImap();
+
+ // Test delegation without mail folders permissions
+ $result = IMAP::shareDefaultFolders($user, $user2, ['event' => 'read-write']);
+ $this->assertTrue($result);
+
+ $acl = $imap->getACL("user/{$user->email}");
+ $this->assertArrayNotHasKey($user2->email, $acl);
+
+ // Test proper delegation case
+ $result = IMAP::shareDefaultFolders($user, $user2, ['mail' => 'read-write']);
+ $this->assertTrue($result);
+
+ $acl = $imap->getACL("user/{$user->email}");
+ $this->assertSame('lrswitedn', implode('', $acl[$user2->email]));
+
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ // User folder as seen by cyrus-admin
+ $folder = str_replace('@', "/{$folder}@", "user/{$user->email}");
+ $acl = $imap->getACL($folder);
+ $this->assertSame('lrswitedn', implode('', $acl[$user2->email]));
+ }
+
+ $imap = $this->getImap($user2->email);
+ $subscribed = $imap->listSubscribed('', "Other Users/*");
+ $expected = ["Other Users/test-{$ts}"];
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ $expected[] = "Other Users/test-{$ts}/{$folder}";
+ }
+
+ asort($subscribed);
+ asort($expected);
+ $this->assertSame(array_values($expected), array_values($subscribed));
+
+ // Test unsubscribing these folders
+ $result = IMAP::unsubscribeSharedFolders($user2, $user->email);
+ $this->assertTrue($result);
+ $this->assertSame([], $imap->listSubscribed("Other Users/test-{$ts}", '*'));
+
+ $imap->closeConnection();
+ $imap = $this->getImap();
+
+ // Test unsharing these folders
+ $result = IMAP::unshareFolders($user, $user2->email);
+ $this->assertTrue($result);
+ $this->assertNotContains($user2->email, $imap->getACL("user/{$user->email}"));
+ foreach (array_keys(\config('services.imap.default_folders')) as $folder) {
+ // User folder as seen by cyrus-admin
+ $folder = str_replace('@', "/{$folder}@", "user/{$user->email}");
+ $this->assertNotContains($user2->email, $imap->getACL($folder));
+ }
+ }
+
/**
* Test creating/updating/deleting a resource
*
@@ -219,9 +275,10 @@
{
Queue::fake();
+ $ts = str_replace('.', '', (string) microtime(true));
$this->resource = $resource = $this->getTestResource(
- 'test-resource-' . time() . '@kolab.org',
- ['name' => 'Resource ©' . time()]
+ "test-resource-{$ts}@kolab.org",
+ ['name' => "Resource © {$ts}"]
);
$resource->setSetting('invitation_policy', 'manual:john@kolab.org');
@@ -236,7 +293,7 @@
$this->assertSame($expectedAcl, $acl);
// Update the resource (rename)
- $resource->name = 'Resource1 ©' . time();
+ $resource->name = "Resource1 © {$ts}";
$resource->save();
$newImapFolder = $resource->getSetting('folder');
@@ -265,9 +322,10 @@
{
Queue::fake();
+ $ts = str_replace('.', '', (string) microtime(true));
$this->folder = $folder = $this->getTestSharedFolder(
- 'test-folder-' . time() . '@kolab.org',
- ['name' => 'SharedFolder ©' . time()]
+ "test-folder-{$ts}@kolab.org",
+ ['name' => "SharedFolder © {$ts}"]
);
$folder->setSetting('acl', json_encode(['john@kolab.org, full', 'jack@kolab.org, read-only']));
@@ -302,7 +360,7 @@
$this->assertSame($expectedAcl, $acl);
// Update the shared folder (rename)
- $folder->name = 'SharedFolder1 ©' . time();
+ $folder->name = "SharedFolder1 © {$ts}";
$folder->save();
$newImapFolder = $folder->getSetting('folder');
@@ -439,6 +497,7 @@
if ($loginAs) {
return $init->invokeArgs(null, [$config, $loginAs]);
}
+
return $this->imap = $init->invokeArgs(null, [$config]);
}
}
diff --git a/src/tests/Feature/Backends/RoundcubeTest.php b/src/tests/Feature/Backends/RoundcubeTest.php
--- a/src/tests/Feature/Backends/RoundcubeTest.php
+++ b/src/tests/Feature/Backends/RoundcubeTest.php
@@ -3,6 +3,8 @@
namespace Tests\Feature\Backends;
use App\Backends\Roundcube;
+use App\Delegation;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class RoundcubeTest extends TestCase
@@ -15,6 +17,7 @@
parent::setUp();
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
}
/**
@@ -23,6 +26,7 @@
public function tearDown(): void
{
$this->deleteTestUser('roundcube@' . \config('app.domain'));
+ $this->deleteTestUser('roundcube-delegatee@' . \config('app.domain'));
parent::tearDown();
}
@@ -64,4 +68,122 @@
$this->assertNull($db->table('users')->where('username', $user->email)->first());
$this->assertNull($db->table('identities')->where('user_id', $rcuser->user_id)->first());
}
+
+ /**
+ * Test creating delegated identities
+ *
+ * @group roundcube
+ */
+ public function testCreateDelegatedIdentities(): void
+ {
+ $delegatee = $this->getTestUser('roundcube-delegatee@' . \config('app.domain'));
+ $user = $this->getTestUser('roundcube@' . \config('app.domain'));
+ $user->setSetting('first_name', 'First');
+ $user->setSetting('last_name', 'Last');
+
+ $db = Roundcube::dbh();
+ $db->table('users')->whereIn('username', [$user->email, $delegatee->email])->delete();
+
+ // Test with both user records not existing (yet)
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $this->assertNotNull($delegatee_id = Roundcube::userId($delegatee->email, false));
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('First Last', $idents[0]->name);
+ $this->assertSame(0, $idents[0]->standard);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // Test with no delegator user record (yet)
+ $db->table('identities')->where('user_id', $delegatee_id)->where('email', $user->email)->delete();
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('First Last', $idents[0]->name);
+ $this->assertSame(0, $idents[0]->standard);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // Test with delegator user record existing and his identity too
+ $db->table('identities')->where('user_id', $delegatee_id)->where('email', $user->email)->delete();
+ $this->assertNotNull($user_id = Roundcube::userId($user->email));
+ $db->table('identities')->where('user_id', $user_id)->update(['name' => 'Test']);
+
+ Roundcube::createDelegatedIdentities($delegatee, $user);
+
+ $idents = $db->table('identities')->where('user_id', $delegatee_id)
+ ->where('email', $user->email)->get();
+ $this->assertCount(1, $idents);
+ $this->assertSame($user->email, $idents[0]->email);
+ $this->assertSame('Test', $idents[0]->name);
+ $this->assertSame(null, $idents[0]->signature);
+
+ // TODO: signatures copying?
+ }
+
+ /**
+ * Test resetting (delegated) identities
+ *
+ * @group roundcube
+ */
+ public function testResetIdentities(): void
+ {
+ Queue::fake();
+
+ // Create two users with aliases and delegation relation
+ $delegatee = $this->getTestUser('roundcube-delegatee@' . \config('app.domain'));
+ $user = $this->getTestUser('roundcube@' . \config('app.domain'));
+ $user->aliases()->create(['alias' => 'alias@' . \config('app.domain')]);
+ $delegatee->aliases()->create(['alias' => 'alias-delegatee@' . \config('app.domain')]);
+
+ $delegation = Delegation::create([
+ 'user_id' => $user->id,
+ 'delegatee_id' => $delegatee->id,
+ ]);
+
+ // Create identities
+ $db = Roundcube::dbh();
+ $db->table('users')->whereIn('username', [$user->email, $delegatee->email])->delete();
+ $id = Roundcube::userId($delegatee->email);
+
+ $emails = [
+ $user->email,
+ 'alias@' . \config('app.domain'),
+ $delegatee->email,
+ 'alias-delegatee@' . \config('app.domain'),
+ ];
+ sort($emails);
+
+ foreach ($emails as $email) {
+ // Note: default identity will be created by userId() above
+ if ($email != $delegatee->email) {
+ $db->table('identities')->insert([
+ 'user_id' => $id,
+ 'email' => $email,
+ 'name' => 'Test',
+ 'changed' => now()->toDateTimeString(),
+ ]);
+ }
+ }
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame($emails, $idents);
+
+ // Test with existing delegation (identities should stay intact)
+ Roundcube::resetIdentities($delegatee);
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame($emails, $idents);
+
+ // Test with no delegation
+ $delegation->delete();
+ Roundcube::resetIdentities($delegatee);
+
+ $idents = $db->table('identities')->where('user_id', $id)->orderBy('email')->pluck('email')->all();
+ $this->assertSame(['alias-delegatee@' . \config('app.domain'), $delegatee->email], $idents);
+ }
}
diff --git a/src/tests/Feature/Controller/PolicyTest.php b/src/tests/Feature/Controller/PolicyTest.php
--- a/src/tests/Feature/Controller/PolicyTest.php
+++ b/src/tests/Feature/Controller/PolicyTest.php
@@ -124,6 +124,87 @@
$this->markTestIncomplete();
}
+ /**
+ * Test submission policy webhook
+ */
+ public function testSubmission()
+ {
+ // Note: Only basic tests here. More detailed policy handler tests are in another place
+
+ // Test invalid sender
+ $post = [
+ 'sender' => 'sender',
+ 'recipients' => ['recipient@gmail.com'],
+ ];
+
+ $response = $this->post('/api/webhooks/policy/submission', $post);
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertEquals('REJECT', $json['response']);
+ $this->assertEquals("Invalid sender", $json['reason']);
+
+ // Test invalid user
+ $post = [
+ 'user' => 'unknown',
+ 'sender' => $this->testUser->email,
+ 'recipients' => ['recipient@gmail.com'],
+ ];
+
+ $response = $this->post('/api/webhooks/policy/submission', $post);
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertEquals('REJECT', $json['response']);
+ $this->assertEquals("Invalid user", $json['reason']);
+
+ // Test unknown user
+ $post = [
+ 'user' => 'unknown@domain.tld',
+ 'sender' => 'john+test@test.domain',
+ 'recipients' => ['recipient@gmail.com'],
+ ];
+
+ $response = $this->post('/api/webhooks/policy/submission', $post);
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertEquals('REJECT', $json['response']);
+ $this->assertEquals("Could not find user {$post['user']}", $json['reason']);
+
+ // Test existing user and an invalid sender address
+ $post = [
+ 'user' => 'john@test.domain',
+ 'sender' => 'john1@test.domain',
+ 'recipients' => ['recipient@gmail.com'],
+ ];
+
+ $response = $this->post('/api/webhooks/policy/submission', $post);
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertEquals('REJECT', $json['response']);
+ $this->assertEquals("john@test.domain is unauthorized to send mail as john1@test.domain", $json['reason']);
+
+ // Test existing user with a valid sender address
+ $post = [
+ 'user' => 'john@test.domain',
+ 'sender' => 'john+test@test.domain',
+ 'recipients' => ['recipient@gmail.com'],
+ ];
+
+ $response = $this->post('/api/webhooks/policy/submission', $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('PERMIT', $json['response']);
+ }
+
/**
* Test ratelimit policy webhook
*/
diff --git a/src/tests/Feature/Controller/User/DelegationTest.php b/src/tests/Feature/Controller/User/DelegationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/User/DelegationTest.php
@@ -0,0 +1,242 @@
+<?php
+
+namespace Tests\Feature\Controller\User;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DelegationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('deleted@kolabnow.com');
+ Delegation::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('deleted@kolabnow.com');
+ Delegation::query()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test delegation creation (POST /api/v4/users/<id>/delegations)
+ */
+ public function testCreateDelegation(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jane = $this->getTestUser('jane@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($jack)->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(403);
+
+ // Test request made by the delegator user
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The email field is required."], $json['errors']['email']);
+ $this->assertSame(["The options field is required."], $json['errors']['options']);
+
+ // Delegatee in another domain (and account) and invalid options
+ $post = ['email' => 'fred@' . \config('app.domain'), 'options' => ['mail' => 're']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The specified email address is not a valid delegation target."], $json['errors']['email']);
+
+ // Invalid options
+ $post = ['email' => $jane->email, 'options' => ['ufo' => 're']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The specified delegation options are invalid."], $json['errors']['options']);
+
+ // Valid input
+ $post = ['email' => $jane->email, 'options' => ['mail' => 'read-only']];
+ $response = $this->actingAs($john)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Delegation created successfully.", $json['message']);
+
+ $delegatee = $john->delegatees()->first();
+ $this->assertSame($jane->email, $delegatee->email);
+ $this->assertSame(['mail' => 'read-only'], $delegatee->delegation->options);
+
+ // Valid input (action taken by another wallet controller)
+ $post = ['email' => $jack->email, 'options' => ['mail' => 'read-only']];
+ $response = $this->actingAs($ned)->post("api/v4/users/{$john->id}/delegations", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(2, $john->delegatees()->count());
+ }
+
+ /**
+ * Test listing delegations (GET /api/v4/users/<id>/delegations)
+ */
+ public function testDelegations(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jane = $this->getTestUser('jane@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $user = $this->getTestUser('deleted@kolabnow.com');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id]);
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jane->id, 'options' => ['mail' => 'r']]);
+
+ // Test unauth access
+ $response = $this->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($user)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(403);
+
+ // Test that non-controller cannot access
+ $response = $this->actingAs($jack)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(403);
+
+ // Test request made by the delegator user
+ $response = $this->actingAs($john)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json['list']);
+ $this->assertSame(2, $json['count']);
+ $this->assertSame($jack->email, $json['list'][0]['email']);
+ $this->assertSame([], $json['list'][0]['options']);
+ $this->assertSame($jane->email, $json['list'][1]['email']);
+ $this->assertSame(['mail' => 'r'], $json['list'][1]['options']);
+
+ // Test request made by the delegators wallet controller
+ $response = $this->actingAs($ned)->get("api/v4/users/{$john->id}/delegations");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json['list']);
+ }
+
+ /**
+ * Test listing delegators (GET /api/v4/users/<id>/delegators)
+ */
+ public function testDelegators(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id, 'options' => ['mail' => 'r']]);
+
+ // Test unauth access
+ $response = $this->get("api/v4/users/{$john->id}/delegators");
+ $response->assertStatus(401);
+
+ // Test that non-controller cannot access other user
+ $response = $this->actingAs($jack)->get("api/v4/users/{$john->id}/delegators");
+ $response->assertStatus(403);
+
+ // Test request made by the delegatee user
+ $response = $this->actingAs($jack)->get("api/v4/users/{$jack->id}/delegators");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame($john->email, $json['list'][0]['email']);
+ $this->assertSame('John Doe', $json['list'][0]['name']);
+ $this->assertSame(['john.doe@kolab.org'], $json['list'][0]['aliases']);
+
+ // Test request made by the delegatee's owner
+ $response = $this->actingAs($john)->get("api/v4/users/{$jack->id}/delegators");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame($john->email, $json['list'][0]['email']);
+ $this->assertSame('John Doe', $json['list'][0]['name']);
+ $this->assertSame(['john.doe@kolab.org'], $json['list'][0]['aliases']);
+ }
+
+ /**
+ * Test delegatee deleting (DELETE /api/v4/users/<id>/delegations/<email>)
+ */
+ public function testDeleteDelegation(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $user = $this->getTestUser('deleted@kolabnow.com');
+
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id]);
+
+ // Test unauth access
+ $response = $this->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(401);
+
+ // Test access to other user/account
+ $response = $this->actingAs($user)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(403);
+
+ // Test that non-controller cannot remove himself
+ $response = $this->actingAs($jack)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(403);
+
+ // Test non-existing delegation
+ $response = $this->actingAs($john)->delete("api/v4/users/{$john->id}/delegations/unknown@kolabnow.com");
+ $response->assertStatus(404);
+
+ // Test successful delegation removal
+ $response = $this->actingAs($john)->delete("api/v4/users/{$john->id}/delegations/{$jack->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals('Delegation deleted successfully.', $json['message']);
+ }
+}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -682,6 +682,7 @@
$this->assertFalse($result['enableFolders']);
$this->assertFalse($result['enableDistlists']);
$this->assertFalse($result['enableResources']);
+ $this->assertTrue($result['enableDelegation']);
}
/**
@@ -1427,6 +1428,7 @@
$this->assertTrue($result['statusInfo']['enableSettings']);
$this->assertTrue($result['statusInfo']['enableDistlists']);
$this->assertTrue($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
// Ned is John's wallet controller
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
@@ -1458,6 +1460,7 @@
$this->assertTrue($result['statusInfo']['enableSettings']);
$this->assertTrue($result['statusInfo']['enableDistlists']);
$this->assertTrue($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
@@ -1494,6 +1497,7 @@
$this->assertFalse($result['statusInfo']['enableSettings']);
$this->assertFalse($result['statusInfo']['enableDistlists']);
$this->assertFalse($result['statusInfo']['enableFolders']);
+ $this->assertTrue($result['statusInfo']['enableDelegation']);
$this->assertFalse($result['isLocked']);
// Test locked user
diff --git a/src/tests/Feature/DelegationTest.php b/src/tests/Feature/DelegationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/DelegationTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Delegation;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DelegationTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('UserAccountA@UserAccount.com');
+ $this->deleteTestUser('UserAccountB@UserAccount.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('UserAccountA@UserAccount.com');
+ $this->deleteTestUser('UserAccountB@UserAccount.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test user deletion regarding delegation relations
+ */
+ public function testDeleteWithDelegation(): void
+ {
+ Queue::fake();
+
+ // Test removing delegatee
+ $userA = $this->getTestUser('UserAccountA@UserAccount.com');
+ $userB = $this->getTestUser('UserAccountB@UserAccount.com');
+
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $delegation->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/Jobs/User/Delegation/CreateTest.php b/src/tests/Feature/Jobs/User/Delegation/CreateTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/User/Delegation/CreateTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Tests\Feature\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\Support\Facades\DAV;
+use App\Support\Facades\IMAP;
+use App\Support\Facades\Roundcube;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('delegation-user1@' . \config('app.domain'));
+ $this->deleteTestUser('delegation-user2@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('delegation-user1@' . \config('app.domain'));
+ $this->deleteTestUser('delegation-user2@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser(
+ 'delegation-user1@' . \config('app.domain'),
+ ['status' => User::STATUS_ACTIVE | User::STATUS_IMAP_READY]
+ );
+ $delegatee = $this->getTestUser(
+ 'delegation-user2@' . \config('app.domain'),
+ ['status' => User::STATUS_ACTIVE | User::STATUS_IMAP_READY]
+ );
+
+ $delegation = Delegation::create(['user_id' => $user->id, 'delegatee_id' => $delegatee->id]);
+
+ $this->assertFalse($delegation->isActive());
+
+ // Test successful creation
+ IMAP::shouldReceive('shareDefaultFolders')->once()->with($user, $delegatee, $delegation->options)
+ ->andReturn(true);
+ DAV::shouldReceive('shareDefaultFolders')->once()->with($user, $delegatee, $delegation->options);
+ Roundcube::shouldReceive('createDelegatedIdentities')->once()->with($delegatee, $user);
+
+ $job = (new \App\Jobs\User\Delegation\CreateJob($delegation->id))->withFakeQueueInteractions();
+ $job->handle();
+ $job->assertNotFailed();
+
+ $this->assertTrue($delegation->fresh()->isActive());
+
+ // TODO: Test all failure cases
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Jobs/User/Delegation/DeleteTest.php b/src/tests/Feature/Jobs/User/Delegation/DeleteTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/User/Delegation/DeleteTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Tests\Feature\Jobs\User\Delegation;
+
+use App\Delegation;
+use App\Support\Facades\DAV;
+use App\Support\Facades\IMAP;
+use App\Support\Facades\Roundcube;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('delegation-user1@' . \config('app.domain'));
+ $this->deleteTestUser('delegation-user2@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('delegation-user1@' . \config('app.domain'));
+ $this->deleteTestUser('delegation-user2@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser(
+ 'delegation-user1@' . \config('app.domain'),
+ ['status' => User::STATUS_ACTIVE | User::STATUS_IMAP_READY]
+ );
+ $delegatee = $this->getTestUser(
+ 'delegation-user2@' . \config('app.domain'),
+ ['status' => User::STATUS_ACTIVE | User::STATUS_IMAP_READY]
+ );
+
+ // Test successful creation
+ IMAP::shouldReceive('unsubscribeSharedFolders')->once()->with($delegatee, $user->email)->andReturn(true);
+ IMAP::shouldReceive('unshareFolders')->once()->with($user, $delegatee->email)->andReturn(true);
+ DAV::shouldReceive('unsubscribeSharedFolders')->once()->with($delegatee, $user->email);
+ DAV::shouldReceive('unshareFolders')->once()->with($user, $delegatee->email);
+ Roundcube::shouldReceive('resetIdentities')->once()->with($delegatee);
+
+ $job = (new \App\Jobs\User\Delegation\DeleteJob($user->email, $delegatee->email))->withFakeQueueInteractions();
+ $job->handle();
+ $job->assertNotFailed();
+
+ // Test that we do nothing if delegation exists
+ Delegation::create(['user_id' => $user->id, 'delegatee_id' => $delegatee->id]);
+
+ $job = (new \App\Jobs\User\Delegation\DeleteJob($user->email, $delegatee->email))->withFakeQueueInteractions();
+ $job->handle();
+ $job->assertNotFailed();
+ }
+}
diff --git a/src/tests/Feature/Policy/SmtpAccessTest.php b/src/tests/Feature/Policy/SmtpAccessTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Policy/SmtpAccessTest.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Feature\Policy;
+
+use App\Delegation;
+use App\Policy\SmtpAccess;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SmtpAccessTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Delegation::query()->delete();
+ $john = $this->getTestUser('john@kolab.org');
+ $john->status &= ~User::STATUS_SUSPENDED;
+ $john->save();
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->status &= ~User::STATUS_SUSPENDED;
+ $jack->save();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Delegation::query()->delete();
+ $john = $this->getTestUser('john@kolab.org');
+ $john->status &= ~User::STATUS_SUSPENDED;
+ $john->save();
+ $jack = $this->getTestUser('jack@kolab.org');
+ $jack->status &= ~User::STATUS_SUSPENDED;
+ $jack->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test verifySender() method
+ */
+ public function testVerifySender(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ // Test main email address
+ $this->assertTrue(SmtpAccess::verifySender($john, ucfirst($john->email)));
+
+ // Test an alias
+ $this->assertTrue(SmtpAccess::verifySender($john, 'John.Doe@kolab.org'));
+
+ // Test another user's email address
+ $this->assertFalse(SmtpAccess::verifySender($jack, $john->email));
+
+ // Test another user's alias
+ $this->assertFalse(SmtpAccess::verifySender($jack, 'john.doe@kolab.org'));
+
+ Queue::fake();
+ Delegation::create(['user_id' => $john->id, 'delegatee_id' => $jack->id]);
+
+ // Test delegator's email address
+ $this->assertTrue(SmtpAccess::verifySender($jack, $john->email));
+
+ // Test delegator's alias
+ $this->assertTrue(SmtpAccess::verifySender($jack, 'john.doe@kolab.org'));
+
+ // Test delegator's alias, but suspended delegator
+ $john->suspend();
+ $this->assertFalse(SmtpAccess::verifySender($jack, 'john.doe@kolab.org'));
+
+ // Test invalid/unknown email
+ $this->assertFalse(SmtpAccess::verifySender($jack, 'unknown'));
+ $this->assertFalse(SmtpAccess::verifySender($jack, 'unknown@domain.tld'));
+
+ // Test suspended user
+ $jack->suspend();
+ $this->assertFalse(SmtpAccess::verifySender($jack, $jack->email));
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -2,6 +2,7 @@
namespace Tests\Feature;
+use App\Delegation;
use App\Domain;
use App\EventLog;
use App\Group;
@@ -949,6 +950,62 @@
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
+ /**
+ * Test user deletion regarding delegation relations
+ */
+ public function testDeleteWithDelegation(): void
+ {
+ Queue::fake();
+
+ // Test removing delegatee
+ $userA = $this->getTestUser('UserAccountA@UserAccount.com');
+ $userB = $this->getTestUser('UserAccountB@UserAccount.com');
+
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $userB->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+
+ $userB->deleted_at = null;
+ $userB->save();
+
+ Queue::fake();
+
+ // Test removing delegator
+ $delegation = new Delegation();
+ $delegation->user_id = $userA->id;
+ $delegation->delegatee_id = $userB->id;
+ $delegation->save();
+
+ $userA->delete();
+
+ $this->assertNull(Delegation::find($delegation->id));
+
+ Queue::assertPushed(\App\Jobs\User\Delegation\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\Delegation\DeleteJob::class,
+ function ($job) use ($userA, $userB) {
+ $delegator = TestCase::getObjectProperty($job, 'delegatorEmail');
+ $delegatee = TestCase::getObjectProperty($job, 'delegateeEmail');
+ return $delegator === $userA->email && $delegatee === $userB->email;
+ }
+ );
+ }
+
/**
* Test user deletion with PGP/WOAT enabled
*/
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -574,7 +574,7 @@
{
Queue::fake();
- $user = User::firstOrCreate(['email' => $email], $attrib);
+ $user = User::withTrashed()->firstOrCreate(['email' => $email], $attrib);
if ($user->trashed()) {
// Note: we do not want to use user restore here
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 9:01 AM (11 h, 55 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18754701
Default Alt Text
D5172.1775379681.diff (147 KB)
Attached To
Mode
D5172: Delegation
Attached
Detach File
Event Timeline