Changeset View
Changeset View
Standalone View
Standalone View
src/app/Backends/IMAP.php
<?php | <?php | ||||
namespace App\Backends; | namespace App\Backends; | ||||
use App\Domain; | use App\Domain; | ||||
use App\Group; | |||||
use App\Resource; | |||||
use App\SharedFolder; | |||||
use App\User; | use App\User; | ||||
class IMAP | class IMAP | ||||
{ | { | ||||
/** @const array Group settings used by the backend */ | |||||
public const GROUP_SETTINGS = []; | |||||
/** @const array Resource settings used by the backend */ | |||||
public const RESOURCE_SETTINGS = [ | |||||
'folder', | |||||
'invitation_policy', | |||||
]; | |||||
/** @const array Shared folder settings used by the backend */ | |||||
public const SHARED_FOLDER_SETTINGS = [ | |||||
'folder', | |||||
'acl', | |||||
]; | |||||
/** @const array User settings used by the backend */ | |||||
public const USER_SETTINGS = []; | |||||
/** @const array Maps Kolab permissions to IMAP permissions */ | |||||
private const ACL_MAP = [ | |||||
'read-only' => 'lrs', | |||||
'read-write' => 'lrswitedn', | |||||
'full' => 'lrswipkxtecdn', | |||||
]; | |||||
/** | /** | ||||
* Check if an account is set up | * Delete a group. | ||||
* | * | ||||
* @param string $username User login (email address) | * @param \App\Group $group Group | ||||
* | * | ||||
* @return bool True if an account exists and is set up, False otherwise | * @return bool True if a group was deleted successfully, False otherwise | ||||
* @throws \Exception | |||||
*/ | */ | ||||
public static function verifyAccount(string $username): bool | 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. | |||||
\App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName); | |||||
return true; | |||||
} | |||||
/** | |||||
* Create a mailbox. | |||||
* | |||||
* @param \App\User $user User | |||||
* | |||||
* @return bool True if a mailbox was created successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function createUser(User $user): bool | |||||
{ | { | ||||
$config = self::getConfig(); | $config = self::getConfig(); | ||||
$imap = self::initIMAP($config, $username); | $imap = self::initIMAP($config); | ||||
$folders = $imap->listMailboxes('', '*'); | $mailbox = self::toUTF7('user/' . $user->email); | ||||
// Mailbox already exists | |||||
if (self::folderExists($imap, $mailbox)) { | |||||
$imap->closeConnection(); | $imap->closeConnection(); | ||||
return true; | |||||
} | |||||
if (!is_array($folders)) { | // Create the mailbox | ||||
throw new \Exception("Failed to get IMAP folders"); | if (!$imap->createFolder($mailbox)) { | ||||
\Log::error("Failed to create mailbox {$mailbox}"); | |||||
$imap->closeConnection(); | |||||
return false; | |||||
} | } | ||||
return count($folders) > 0; | // Wait until it's propagated (for Cyrus Murder setup) | ||||
// FIXME: Do we still need this? | |||||
if (strpos($imap->conn->data['GREETING'] ?? '', 'Cyrus IMAP Murder') !== false) { | |||||
$tries = 30; | |||||
while ($tries-- > 0) { | |||||
$folders = $imap->listMailboxes('', $mailbox); | |||||
if (is_array($folders) && count($folders)) { | |||||
break; | |||||
} | |||||
sleep(1); | |||||
$imap->closeConnection(); | |||||
$imap = self::initIMAP($config); | |||||
} | |||||
} | |||||
// Set quota | |||||
$quota = $user->countEntitlementsBySku('storage') * 1048576; | |||||
if ($quota) { | |||||
$imap->setQuota($mailbox, ['storage' => $quota]); | |||||
} | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
/** | |||||
* Delete a mailbox. | |||||
* | |||||
* @param \App\User $user User | |||||
* | |||||
* @return bool True if a mailbox was deleted successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function deleteUser(User $user): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$mailbox = self::toUTF7('user/' . $user->email); | |||||
// To delete the mailbox cyrus-admin needs extra permissions | |||||
$imap->setACL($mailbox, $config['user'], 'c'); | |||||
// Delete the mailbox (no need to delete subfolders?) | |||||
$result = $imap->deleteFolder($mailbox); | |||||
$imap->closeConnection(); | |||||
// Cleanup ACL | |||||
\App\Jobs\IMAP\AclCleanupJob::dispatch($user->email); | |||||
return $result; | |||||
} | |||||
/** | |||||
* Update a mailbox (quota). | |||||
* | |||||
* @param \App\User $user User | |||||
* | |||||
* @return bool True if a mailbox was updated successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function updateUser(User $user): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$mailbox = self::toUTF7('user/' . $user->email); | |||||
$result = true; | |||||
// Set quota | |||||
$quota = $user->countEntitlementsBySku('storage') * 1048576; | |||||
if ($quota) { | |||||
$result = $imap->setQuota($mailbox, ['storage' => $quota]); | |||||
} | |||||
$imap->closeConnection(); | |||||
return $result; | |||||
} | |||||
/** | |||||
* Create a resource. | |||||
* | |||||
* @param \App\Resource $resource Resource | |||||
* | |||||
* @return bool True if a resource was created successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function createResource(Resource $resource): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $resource->getSettings(['invitation_policy', 'folder']); | |||||
$mailbox = self::toUTF7($settings['folder']); | |||||
// Mailbox already exists | |||||
if (self::folderExists($imap, $mailbox)) { | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
// Create the shared folder | |||||
if (!$imap->createFolder($mailbox)) { | |||||
\Log::error("Failed to create mailbox {$mailbox}"); | |||||
$imap->closeConnection(); | |||||
return false; | |||||
} | |||||
// Set folder type | |||||
$imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => 'event']); | |||||
// Set ACL | |||||
if (!empty($settings['invitation_policy'])) { | |||||
if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { | |||||
self::aclUpdate($imap, $mailbox, ["{$m[1]}, full"]); | |||||
} | |||||
} | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
/** | |||||
* Update a resource. | |||||
* | |||||
* @param \App\Resource $resource Resource | |||||
* @param array $props Old resource properties | |||||
* | |||||
* @return bool True if a resource was updated successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function updateResource(Resource $resource, array $props = []): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $resource->getSettings(['invitation_policy', 'folder']); | |||||
$folder = $settings['folder']; | |||||
$mailbox = self::toUTF7($folder); | |||||
// Rename the mailbox (only possible if we have the old folder) | |||||
if (!empty($props['folder']) && $props['folder'] != $folder) { | |||||
$oldMailbox = self::toUTF7($props['folder']); | |||||
if (!$imap->renameFolder($oldMailbox, $mailbox)) { | |||||
\Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); | |||||
$imap->closeConnection(); | |||||
return false; | |||||
} | |||||
} | |||||
// ACL | |||||
$acl = []; | |||||
if (!empty($settings['invitation_policy'])) { | |||||
if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { | |||||
$acl = ["{$m[1]}, full"]; | |||||
} | |||||
} | |||||
self::aclUpdate($imap, $mailbox, $acl); | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
/** | |||||
* Delete a resource. | |||||
* | |||||
* @param \App\Resource $resource Resource | |||||
* | |||||
* @return bool True if a resource was deleted successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function deleteResource(Resource $resource): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $resource->getSettings(['folder']); | |||||
$mailbox = self::toUTF7($settings['folder']); | |||||
// To delete the mailbox cyrus-admin needs extra permissions | |||||
$imap->setACL($mailbox, $config['user'], 'c'); | |||||
// Delete the mailbox (no need to delete subfolders?) | |||||
$result = $imap->deleteFolder($mailbox); | |||||
$imap->closeConnection(); | |||||
return $result; | |||||
} | |||||
/** | |||||
* Create a shared folder. | |||||
* | |||||
* @param \App\SharedFolder $folder Shared folder | |||||
* | |||||
* @return bool True if a falder was created successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function createSharedFolder(SharedFolder $folder): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $folder->getSettings(['acl', 'folder']); | |||||
$acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; | |||||
$mailbox = self::toUTF7($settings['folder']); | |||||
// Mailbox already exists | |||||
if (self::folderExists($imap, $mailbox)) { | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
// Create the mailbox | |||||
if (!$imap->createFolder($mailbox)) { | |||||
\Log::error("Failed to create mailbox {$mailbox}"); | |||||
$imap->closeConnection(); | |||||
return false; | |||||
} | |||||
// Set folder type | |||||
$imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => $folder->type]); | |||||
// Set ACL | |||||
self::aclUpdate($imap, $mailbox, $acl); | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
/** | |||||
* Update a shared folder. | |||||
* | |||||
* @param \App\SharedFolder $folder Shared folder | |||||
* @param array $props Old folder properties | |||||
* | |||||
* @return bool True if a falder was updated successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function updateSharedFolder(SharedFolder $folder, array $props = []): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $folder->getSettings(['acl', 'folder']); | |||||
$acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null; | |||||
$folder = $settings['folder']; | |||||
$mailbox = self::toUTF7($folder); | |||||
// Rename the mailbox | |||||
if (!empty($props['folder']) && $props['folder'] != $folder) { | |||||
$oldMailbox = self::toUTF7($props['folder']); | |||||
if (!$imap->renameFolder($oldMailbox, $mailbox)) { | |||||
\Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); | |||||
$imap->closeConnection(); | |||||
return false; | |||||
} | |||||
} | |||||
// Note: Shared folder type does not change | |||||
// ACL | |||||
self::aclUpdate($imap, $mailbox, $acl); | |||||
$imap->closeConnection(); | |||||
return true; | |||||
} | |||||
/** | |||||
* Delete a shared folder. | |||||
* | |||||
* @param \App\SharedFolder $folder Shared folder | |||||
* | |||||
* @return bool True if a falder was deleted successfully, False otherwise | |||||
* @throws \Exception | |||||
*/ | |||||
public static function deleteSharedFolder(SharedFolder $folder): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
$settings = $folder->getSettings(['folder']); | |||||
$mailbox = self::toUTF7($settings['folder']); | |||||
// To delete the mailbox cyrus-admin needs extra permissions | |||||
$imap->setACL($mailbox, $config['user'], 'c'); | |||||
// Delete the mailbox | |||||
$result = $imap->deleteFolder($mailbox); | |||||
$imap->closeConnection(); | |||||
return $result; | |||||
} | } | ||||
/** | /** | ||||
* Check if a shared folder is set up. | * Check if a shared folder is set up. | ||||
* | * | ||||
* @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld | * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld | ||||
* | * | ||||
* @return bool True if a folder exists and is set up, False otherwise | * @return bool True if a folder exists and is set up, False otherwise | ||||
*/ | */ | ||||
public static function verifySharedFolder(string $folder): bool | public static function verifySharedFolder(string $folder): bool | ||||
{ | { | ||||
$config = self::getConfig(); | $config = self::getConfig(); | ||||
$imap = self::initIMAP($config); | $imap = self::initIMAP($config); | ||||
// Convert the folder from UTF8 to UTF7-IMAP | // Convert the folder from UTF8 to UTF7-IMAP | ||||
if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { | if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { | ||||
$folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); | $folderName = self::toUTF7($matches[2]); | ||||
$folder = $matches[1] . $folderName . $matches[3]; | $folder = $matches[1] . $folderName . $matches[3]; | ||||
} | } | ||||
// FIXME: just listMailboxes() does not return shared folders at all | // FIXME: just listMailboxes() does not return shared folders at all | ||||
$metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); | $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); | ||||
$imap->closeConnection(); | $imap->closeConnection(); | ||||
// Note: We have to use error code to distinguish an error from "no mailbox" response | // Note: We have to use error code to distinguish an error from "no mailbox" response | ||||
if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { | if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { | ||||
return false; | return false; | ||||
} | } | ||||
if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { | if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { | ||||
throw new \Exception("Failed to get folder metadata from IMAP"); | throw new \Exception("Failed to get folder metadata from IMAP"); | ||||
} | } | ||||
return true; | return true; | ||||
} | } | ||||
/** | /** | ||||
* Convert UTF8 string to UTF7-IMAP encoding | |||||
*/ | |||||
public static function toUTF7(string $string): string | |||||
{ | |||||
return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8'); | |||||
} | |||||
/** | |||||
* Check if an account is set up | |||||
* | |||||
* @param string $username User login (email address) | |||||
* | |||||
* @return bool True if an account exists and is set up, False otherwise | |||||
*/ | |||||
public static function verifyAccount(string $username): bool | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config, $username); | |||||
$folders = $imap->listMailboxes('', '*'); | |||||
$imap->closeConnection(); | |||||
if (!is_array($folders)) { | |||||
throw new \Exception("Failed to get IMAP folders"); | |||||
} | |||||
return count($folders) > 0; | |||||
machniak: We throw exceptions on imap errors because worker will handle this as a job failure and try… | |||||
Not Done Inline ActionsverifyAccount is currently primarily used in the tests, so let's throw in the job if necessary I propose. mollekopf: verifyAccount is currently primarily used in the tests, so let's throw in the job if necessary… | |||||
Done Inline ActionsIt was invoked from the observer https://git.kolab.org/source/kolab/browse/master/src/app/Observers/UserObserver.php$69 With this whole refactor I propose to call IMAP::verifyAccount() from the CreateJob. machniak: It was invoked from the observer https://git.kolab. | |||||
} | |||||
/** | |||||
* Remove ACL for a specified user/group anywhere in the IMAP | |||||
* | |||||
* @param string $ident ACL identifier (user email or e.g. group name) | |||||
* @param string $domain ACL domain | |||||
*/ | |||||
public static function aclCleanup(string $ident, string $domain = ''): void | |||||
{ | |||||
$config = self::getConfig(); | |||||
$imap = self::initIMAP($config); | |||||
if (strpos($ident, '@')) { | |||||
$domain = explode('@', $ident, 2)[1]; | |||||
} | |||||
$callback = function ($folder) use ($imap, $ident) { | |||||
$acl = $imap->getACL($folder); | |||||
if (is_array($acl) && isset($acl[$ident])) { | |||||
$imap->deleteACL($folder, $ident); | |||||
} | |||||
}; | |||||
$folders = $imap->listMailboxes('', "user/*@{$domain}"); | |||||
if (!is_array($folders)) { | |||||
$imap->closeConnection(); | |||||
throw new \Exception("Failed to get IMAP folders"); | |||||
} | |||||
array_walk($folders, $callback); | |||||
$folders = $imap->listMailboxes('', "shared/*@{$domain}"); | |||||
if (!is_array($folders)) { | |||||
$imap->closeConnection(); | |||||
throw new \Exception("Failed to get IMAP folders"); | |||||
} | |||||
array_walk($folders, $callback); | |||||
$imap->closeConnection(); | |||||
} | |||||
/** | |||||
* Convert Kolab ACL into IMAP user->rights array | |||||
*/ | |||||
private static function aclToImap($acl): array | |||||
{ | |||||
if (empty($acl)) { | |||||
return []; | |||||
} | |||||
return \collect($acl) | |||||
->mapWithKeys(function ($item, $key) { | |||||
list($user, $rights) = explode(',', $item, 2); | |||||
return [trim($user) => self::ACL_MAP[trim($rights)]]; | |||||
}) | |||||
->all(); | |||||
} | |||||
/** | |||||
* Update folder ACL | |||||
*/ | |||||
private static function aclUpdate($imap, $mailbox, $acl, bool $isNew = false) | |||||
{ | |||||
$imapAcl = $isNew ? [] : $imap->getACL($mailbox); | |||||
if (is_array($imapAcl)) { | |||||
foreach (self::aclToImap($acl) as $user => $rights) { | |||||
if (empty($imapAcl[$user]) || implode('', $imapAcl[$user]) !== $rights) { | |||||
$imap->setACL($mailbox, $user, $rights); | |||||
} | |||||
unset($imapAcl[$user]); | |||||
} | |||||
foreach ($imapAcl as $user => $rights) { | |||||
$imap->deleteACL($mailbox, $user); | |||||
} | |||||
} | |||||
} | |||||
/** | |||||
* Check if an IMAP folder exists | |||||
*/ | |||||
private static function folderExists($imap, string $folder): bool | |||||
{ | |||||
$folders = $imap->listMailboxes('', $folder); | |||||
if (!is_array($folders)) { | |||||
$imap->closeConnection(); | |||||
throw new \Exception("Failed to get IMAP folders"); | |||||
} | |||||
return count($folders) > 0; | |||||
} | |||||
/** | |||||
* Initialize connection to IMAP | * Initialize connection to IMAP | ||||
*/ | */ | ||||
private static function initIMAP(array $config, string $login_as = null) | private static function initIMAP(array $config, string $login_as = null) | ||||
{ | { | ||||
$imap = new \rcube_imap_generic(); | $imap = new \rcube_imap_generic(); | ||||
if (\config('app.debug')) { | if (\config('app.debug')) { | ||||
$imap->setDebug(true, 'App\Backends\IMAP::logDebug'); | $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); | ||||
▲ Show 20 Lines • Show All 70 Lines • Show Last 20 Lines |
We throw exceptions on imap errors because worker will handle this as a job failure and try again. If we just return false the job is not retried.
That being said... verifyAccount() (and VerifyJob) is not used anymore in this branch. With app.with_imap introduction we should call IMAP::verifyAccount() (not the job, the job could be removed later) from the CreateJob for "app.with_imap is disabled" case.