mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-28 20:23:26 +00:00
[Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync
This commit is contained in:
82 files changed
+6488
-1027
No files matched your search
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use Mailcow\MailboxGroup\Service\AccountHooks;
|
||||
use Mailcow\MailboxGroup\Service\GroupService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php and edit.php (routes `mailbox-group`), plus the hook adapters the legacy
|
||||
* code calls outside a transaction (a database error becomes a `mysql_error` message).
|
||||
* Messages are logged as ['mailbox_group', action, 'group', data].
|
||||
*/
|
||||
class MailboxGroupApiController
|
||||
{
|
||||
public function __construct(private GroupService $groups, private AccountHooks $hooks, private TemplateService $templates,
|
||||
private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/mailbox-group */
|
||||
public function addGroup(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||
$group = $this->groups->create($attr);
|
||||
$this->warnings($group->warnings, $log);
|
||||
return $this->response->success(['group_added', htmlspecialchars($group->name)], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/mailbox-group: one message per id */
|
||||
public function editGroups(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->groups->requireEditor();
|
||||
return $this->batch((array)($data['id'] ?? []), $log, function (int $id) use ($attr, $log): array {
|
||||
$group = $this->groups->update($id, $attr);
|
||||
$this->warnings($group->warnings, $log);
|
||||
return ['group_modified', htmlspecialchars($group->name)];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/mailbox-group: one message per id */
|
||||
public function deleteGroups(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->groups->requireEditor();
|
||||
return $this->batch($items, $log, function (int $id) use ($log): array {
|
||||
$group = $this->groups->delete($id);
|
||||
$this->warnings($group->warnings, $log);
|
||||
return ['group_removed', htmlspecialchars($group->name)];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** GET mailbox-group/all[/<domain,domain>] */
|
||||
public function getGroups(?string $domains = null): array
|
||||
{
|
||||
$list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||
return array_values(array_filter(array_map(fn(int $id): array => $this->getGroup($id), $this->groups->visibleIds($list))));
|
||||
}
|
||||
|
||||
/** GET mailbox-group/<id>, also edit.php */
|
||||
public function getGroup(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$group = ($id === null) ? null : $this->groups->get($id);
|
||||
return ($group === null) ? [] : $this->groups->toArray($group);
|
||||
}
|
||||
|
||||
/** GET mailbox-group/member/<username>: groups of a mailbox (mailbox edit page, user page) */
|
||||
public function getMemberships(string $username): array
|
||||
{
|
||||
return $this->groups->membershipsOf($username);
|
||||
}
|
||||
|
||||
public function mailboxDeleted(string $username): void
|
||||
{
|
||||
$this->hook('mailbox_deleted', $username, fn() => $this->hooks->onMailboxDeleted($username));
|
||||
}
|
||||
|
||||
public function domainDeleted(string $domain): void
|
||||
{
|
||||
$this->hook('domain_deleted', $domain, fn() => $this->hooks->onDomainDeleted($domain));
|
||||
}
|
||||
|
||||
public function addressDeleted(string $address): void
|
||||
{
|
||||
$this->hook('address_deleted', $address, fn() => $this->hooks->onAddressDeleted($address));
|
||||
}
|
||||
|
||||
public function addressRenamed(string $old, string $new): void
|
||||
{
|
||||
$this->hook('address_renamed', [$old, $new], fn() => $this->hooks->onAddressRenamed($old, $new));
|
||||
}
|
||||
|
||||
/** A mailbox template was edited: apply it where it is the effective group template */
|
||||
public function templateChanged(int $templateId): void
|
||||
{
|
||||
$this->hook('template_changed', $templateId, function () use ($templateId): void {
|
||||
$this->warnings($this->templates->onTemplateChanged($templateId), ['mailbox_group', 'edit', 'template', $templateId]);
|
||||
});
|
||||
}
|
||||
|
||||
private function warnings(array $warnings, array $log): void
|
||||
{
|
||||
foreach ($warnings as $msg) {
|
||||
$this->response->warning($msg, $log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
|
||||
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
|
||||
*/
|
||||
private function handle(string $action, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['mailbox_group', $action, 'group', $data];
|
||||
try {
|
||||
$result = $work($log);
|
||||
} catch (UserError $e) {
|
||||
$result = $this->response->error($e, $log);
|
||||
} catch (PDOException $e) {
|
||||
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
|
||||
}
|
||||
$this->response->flush();
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function hook(string $action, mixed $data, callable $work): void
|
||||
{
|
||||
try {
|
||||
$work();
|
||||
} catch (PDOException $e) {
|
||||
$this->response->danger(['mysql_error', $e->getMessage()], ['mailbox_group', $action, 'hook', $data]);
|
||||
}
|
||||
$this->response->flush();
|
||||
}
|
||||
|
||||
/** One message per id: the success message returned by $work(id), or the UserError */
|
||||
private function batch(array $ids, array $log, callable $work): bool
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
$this->response->success($work($this->requireId($id)), $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else */
|
||||
private function toId(mixed $id): ?int
|
||||
{
|
||||
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for ids that cannot exist */
|
||||
private function requireId(mixed $id): int
|
||||
{
|
||||
return $this->toId($id) ?? throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Legacy;
|
||||
|
||||
/**
|
||||
* Bridge to the procedural mailbox code. The global calls sit in protected methods so tests can replace them.
|
||||
*/
|
||||
class LegacyMailbox
|
||||
{
|
||||
/**
|
||||
* Applies a mailbox template like the IdP code does: as system caller, with the current name, so only the
|
||||
* template attributes change.
|
||||
*
|
||||
* @return ?string null on success, else the message key of the legacy error
|
||||
*/
|
||||
public function applyTemplate(string $username, string $name, string $template): ?string
|
||||
{
|
||||
return $this->asSystem(fn(): mixed => $this->callTemplate($username, $name, $template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a legacy call as system caller (access_all_exception); the caller's session messages are kept.
|
||||
*
|
||||
* @return ?string null unless the call returned false, else the message key of the legacy error
|
||||
*/
|
||||
protected function asSystem(callable $call): ?string
|
||||
{
|
||||
$hasReturn = array_key_exists('return', $_SESSION ?? []);
|
||||
$return = $_SESSION['return'] ?? null;
|
||||
$accessAll = $_SESSION['access_all_exception'] ?? null;
|
||||
$_SESSION['return'] = [];
|
||||
$_SESSION['access_all_exception'] = '1';
|
||||
try {
|
||||
if ($call() !== false) {
|
||||
return null;
|
||||
}
|
||||
foreach ($_SESSION['return'] as $message) {
|
||||
if (($message['type'] ?? '') === 'danger') {
|
||||
return (string)(is_array($message['msg']) ? $message['msg'][0] : $message['msg']);
|
||||
}
|
||||
}
|
||||
return 'generic_server_error';
|
||||
} finally {
|
||||
if ($hasReturn) {
|
||||
$_SESSION['return'] = $return;
|
||||
} else {
|
||||
unset($_SESSION['return']);
|
||||
}
|
||||
$_SESSION['access_all_exception'] = $accessAll ?? '0';
|
||||
}
|
||||
}
|
||||
|
||||
protected function callTemplate(string $username, string $name, string $template): mixed
|
||||
{
|
||||
return mailbox('edit', 'mailbox_from_template', ['username' => $username, 'name' => $name, 'template' => $template]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Dovecot\DovecotModule;
|
||||
use Mailcow\MailboxGroup\Controller\MailboxGroupApiController;
|
||||
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
use Mailcow\MailboxGroup\Service\AccountHooks;
|
||||
use Mailcow\MailboxGroup\Service\ChangeEffects;
|
||||
use Mailcow\MailboxGroup\Service\GroupService;
|
||||
use Mailcow\MailboxGroup\Service\IdpSyncService;
|
||||
use Mailcow\MailboxGroup\Service\SharingService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
use Mailcow\Sogo\SogoModule;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the mailbox group module.
|
||||
*
|
||||
* process_add_return(MailboxGroupModule::fromGlobals()->api->addGroup($attr));
|
||||
* MailboxGroupModule::fromGlobals(Context::system())->templates->templateForLogin($username, $legacyTemplate);
|
||||
* MailboxGroupModule::create($pdo, Context::system())->sharing->reconcile(); // cron
|
||||
*/
|
||||
class MailboxGroupModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly GroupService $groups,
|
||||
public readonly TemplateService $templates,
|
||||
public readonly AccountHooks $hooks,
|
||||
public readonly IdpSyncService $idp,
|
||||
public readonly SharingService $sharing,
|
||||
public readonly MailboxGroupApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$repo = new GroupRepository($pdo);
|
||||
$lookup = new LookupRepository($pdo);
|
||||
$dovecot = DovecotModule::create()->dovecot;
|
||||
$sogo = SogoModule::create()->sogo;
|
||||
$templates = new TemplateService($lookup, new LegacyMailbox());
|
||||
$effects = new ChangeEffects($templates, $sogo, $repo, $dovecot);
|
||||
$sharing = new SharingService($repo, $dovecot, $sogo, $effects);
|
||||
$groups = new GroupService($repo, $lookup, $templates, $effects, $sharing, $context);
|
||||
$hooks = new AccountHooks($repo, $effects);
|
||||
return new self($groups, $templates, $hooks, new IdpSyncService($repo, $lookup, $effects), $sharing,
|
||||
new MailboxGroupApiController($groups, $hooks, $templates, new ApiResponse()));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Model;
|
||||
|
||||
/**
|
||||
* A mailbox group (table `mailbox_group`) with its members and send-as grants.
|
||||
*
|
||||
* The group tables are the source of truth. GroupRepository::rebuild() derives the rows other services read:
|
||||
* the alias row of the distribution address and the sender_acl rows of the grants, both tagged with `group_id`.
|
||||
* Not to be confused with SOGo resources of kind 'group' (`mailbox.kind`).
|
||||
*/
|
||||
class Group
|
||||
{
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
public const SOURCE_IDP = 'idp';
|
||||
|
||||
public ?int $id = null;
|
||||
public string $domain = '';
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
/** Distribution address, null = group only used for grants and templates */
|
||||
public ?string $address = null;
|
||||
/** Members may send as the address */
|
||||
public int $senderAllowed = 0;
|
||||
public int $sogoVisible = 0;
|
||||
/** Only senders of the domain may write to the address */
|
||||
public int $internal = 0;
|
||||
public ?int $templateId = null;
|
||||
/** Template name, read only */
|
||||
public ?string $template = null;
|
||||
public int $priority = 0;
|
||||
/** IdP group id, set = IdP memberships are synced */
|
||||
public ?string $idpRef = null;
|
||||
public int $active = 1;
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
/** @var array<string, string> username => source */
|
||||
public array $members = [];
|
||||
/** @var string[] addresses or @domain */
|
||||
public array $sendAs = [];
|
||||
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
|
||||
public array $imapGrants = [];
|
||||
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
|
||||
public array $sogoGrants = [];
|
||||
/** @var array[] warnings of the last write (msg values), not stored */
|
||||
public array $warnings = [];
|
||||
|
||||
/** Builds a group from a `mailbox_group` row (`template_name` optional) */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$group = new self();
|
||||
$group->id = isset($row['id']) ? (int)$row['id'] : null;
|
||||
$group->domain = (string)($row['domain'] ?? '');
|
||||
$group->name = (string)($row['name'] ?? '');
|
||||
$group->description = (string)($row['description'] ?? '');
|
||||
$group->address = isset($row['address']) ? (string)$row['address'] : null;
|
||||
$group->senderAllowed = (int)($row['sender_allowed'] ?? 0);
|
||||
$group->sogoVisible = (int)($row['sogo_visible'] ?? 0);
|
||||
$group->internal = (int)($row['internal'] ?? 0);
|
||||
$group->templateId = isset($row['template_id']) ? (int)$row['template_id'] : null;
|
||||
$group->template = isset($row['template_name']) ? (string)$row['template_name'] : null;
|
||||
$group->priority = (int)($row['priority'] ?? 0);
|
||||
$group->idpRef = isset($row['idp_ref']) ? (string)$row['idp_ref'] : null;
|
||||
$group->active = (int)($row['active'] ?? 1);
|
||||
$group->created = isset($row['created']) ? (string)$row['created'] : null;
|
||||
$group->modified = isset($row['modified']) ? (string)$row['modified'] : null;
|
||||
return $group;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function usernames(): array
|
||||
{
|
||||
return array_keys($this->members);
|
||||
}
|
||||
|
||||
/** API representation (GET mailbox-group) */
|
||||
public function toArray(bool $canEdit): array
|
||||
{
|
||||
$members = [];
|
||||
foreach ($this->members as $username => $source) {
|
||||
$members[] = ['username' => (string)$username, 'source' => $source];
|
||||
}
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'address' => $this->address,
|
||||
'sender_allowed' => $this->senderAllowed,
|
||||
'sogo_visible' => $this->sogoVisible,
|
||||
'internal' => $this->internal,
|
||||
'template_id' => $this->templateId,
|
||||
'template' => $this->template,
|
||||
'priority' => $this->priority,
|
||||
'idp_ref' => $this->idpRef,
|
||||
'members' => $members,
|
||||
'send_as' => $this->sendAs,
|
||||
'imap_grants' => $this->imapGrants,
|
||||
'sogo_grants' => $this->sogoGrants,
|
||||
'active' => $this->active,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
'can_edit' => $canEdit,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Repository;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for `mailbox_group`, its members and send-as grants, and the rows derived from them (rebuild()).
|
||||
* Every write rebuilds the derived rows in the same transaction, so readers never see a half-applied group.
|
||||
*/
|
||||
class GroupRepository extends Repository
|
||||
{
|
||||
/** SOGo roles per folder type and access of a SOGo grant */
|
||||
public const SOGO_ROLES = [
|
||||
'Calendar' => [
|
||||
'read' => ['PublicViewer', 'ConfidentialViewer', 'PrivateDAndTViewer'],
|
||||
'write' => ['PublicModifier', 'ConfidentialModifier', 'PrivateDAndTViewer', 'ObjectCreator', 'ObjectEraser'],
|
||||
],
|
||||
'Contacts' => [
|
||||
'read' => ['ObjectViewer'],
|
||||
'write' => ['ObjectViewer', 'ObjectCreator', 'ObjectEditor', 'ObjectEraser'],
|
||||
],
|
||||
];
|
||||
|
||||
/** Grant table, folder and extra "access gone" condition of the subscriptions per kind */
|
||||
private const SUBSCRIPTION_GRANTS = [
|
||||
'imap' => ['table' => 'mailbox_group_imap_grant', 'folder' => "''", 'gone' => " OR g.`state` = 'removing'", 'valid' => " AND g2.`state` != 'removing'"],
|
||||
'sogo' => ['table' => 'mailbox_group_sogo_grant', 'folder' => "CONCAT(g.`folder_type`, '/', g.`folder`)", 'gone' => '', 'valid' => ''],
|
||||
];
|
||||
|
||||
/** Side effects of the writes since the last takeEffects(): members whose acl_groups changed, sogo_acl changed */
|
||||
private array $effects = ['members' => [], 'sogo_acl' => false];
|
||||
/** Members removed by writeMembers(), rebuild() decides whether Dovecot needs to know */
|
||||
private array $removedMembers = [];
|
||||
|
||||
public function find(int $id): ?Group
|
||||
{
|
||||
return $this->fetch('1=1', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** Like find(), but only when the group is visible to the caller */
|
||||
public function findVisible(int $id, Context $context): ?Group
|
||||
{
|
||||
[$visible, $params] = $this->visibility($context);
|
||||
return $this->fetch($visible, $params + ['id' => $id]);
|
||||
}
|
||||
|
||||
public function findByIdpRef(string $ref): ?Group
|
||||
{
|
||||
$id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `idp_ref` = :ref", ['ref' => $ref])->fetchColumn();
|
||||
return ($id === false) ? null : $this->find((int)$id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $domains limit to these domains, [] = all visible
|
||||
* @return int[] ids of the groups visible to the caller, ordered by domain and name
|
||||
*/
|
||||
public function visibleIds(Context $context, array $domains = []): array
|
||||
{
|
||||
[$visible, $params] = $this->visibility($context);
|
||||
$sql = "SELECT g.`id` FROM `mailbox_group` g WHERE $visible";
|
||||
if (!empty($domains)) {
|
||||
$names = [];
|
||||
foreach (array_values($domains) as $i => $domain) {
|
||||
$names[] = ':dom' . $i;
|
||||
$params['dom' . $i] = $domain;
|
||||
}
|
||||
$sql .= " AND g.`domain` IN (" . implode(', ', $names) . ")";
|
||||
}
|
||||
return array_map('intval', $this->query($sql . " ORDER BY g.`domain`, g.`name`", $params)->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mailbox_group` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return string[] usernames whose SOGo identities changed */
|
||||
public function insert(Group $group): array
|
||||
{
|
||||
return $this->transaction(function () use ($group): array {
|
||||
$group->id = $this->insertRow('mailbox_group', $this->values($group));
|
||||
$this->writeMembers($group);
|
||||
$this->writeSendAs($group);
|
||||
$this->writeGrants($group);
|
||||
return $this->rebuild((int)$group->id);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return string[] usernames whose SOGo identities changed */
|
||||
public function update(Group $group): array
|
||||
{
|
||||
return $this->transaction(function () use ($group): array {
|
||||
$this->updateRow('mailbox_group', (int)$group->id, $this->values($group));
|
||||
$this->writeMembers($group);
|
||||
$this->writeSendAs($group);
|
||||
$this->writeGrants($group);
|
||||
return $this->rebuild((int)$group->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the group; members, grants and the derived alias/sender_acl rows go via FK cascade.
|
||||
*
|
||||
* @return string[] usernames whose SOGo identities changed
|
||||
*/
|
||||
public function delete(int $id): array
|
||||
{
|
||||
return $this->transaction(function () use ($id): array {
|
||||
$before = $this->aliasState($id);
|
||||
$this->query("DELETE FROM `mailbox_group` WHERE `id` = :id", ['id' => $id]);
|
||||
$this->cleanupSogoAcl();
|
||||
return $before['members'];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the rows derived from the group: the alias row of its address (goto = members, inactive while the
|
||||
* group is inactive or empty, so the address bounces but stays reserved) and one sender_acl row per member
|
||||
* and send-as grant, and the sogo_acl rows of its SOGo grants. Idempotent, joins the caller's transaction.
|
||||
* $sogo = false keeps the sogo_acl rows (mailbox rename: sogo-tool rename-user updates them afterwards).
|
||||
*
|
||||
* @return string[] usernames whose SOGo identities changed
|
||||
*/
|
||||
public function rebuild(int $id, bool $sogo = true): array
|
||||
{
|
||||
return $this->transaction(function () use ($id, $sogo): array {
|
||||
$group = $this->query("SELECT * FROM `mailbox_group` WHERE `id` = :id FOR UPDATE", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$group) {
|
||||
return [];
|
||||
}
|
||||
$before = $this->aliasState($id);
|
||||
$members = $this->query("SELECT `username` FROM `mailbox_group_member` WHERE `group_id` = :id ORDER BY `username`",
|
||||
['id' => $id])->fetchAll(PDO::FETCH_COLUMN);
|
||||
$visible = false;
|
||||
if ($group['address'] === null) {
|
||||
$this->query("DELETE FROM `alias` WHERE `group_id` = :id", ['id' => $id]);
|
||||
} else {
|
||||
$values = [
|
||||
'address' => $group['address'],
|
||||
'goto' => empty($members) ? 'null@localhost' : implode(',', $members),
|
||||
'domain' => $group['domain'],
|
||||
'public_comment' => $group['description'],
|
||||
'sender_allowed' => (int)$group['sender_allowed'],
|
||||
// SOGo identities only make sense when the members may send as the address
|
||||
'sogo_visible' => ($group['sogo_visible'] == 1 && $group['sender_allowed'] == 1) ? 1 : 0,
|
||||
'internal' => (int)$group['internal'],
|
||||
'active' => ($group['active'] == 1 && !empty($members)) ? 1 : 0,
|
||||
];
|
||||
// Never insert by address: an existing foreign row must not be taken over
|
||||
$aliasId = $this->query("SELECT `id` FROM `alias` WHERE `group_id` = :id", ['id' => $id])->fetchColumn();
|
||||
if ($aliasId === false) {
|
||||
$this->insertRow('alias', $values + ['group_id' => $id]);
|
||||
} else {
|
||||
$this->updateRow('alias', (int)$aliasId, $values);
|
||||
}
|
||||
$visible = ($values['active'] === 1 && $values['sogo_visible'] === 1);
|
||||
}
|
||||
$this->query("DELETE FROM `sender_acl` WHERE `group_id` = :id", ['id' => $id]);
|
||||
if ($group['active'] == 1) {
|
||||
$this->query("INSERT INTO `sender_acl` (`logged_in_as`, `send_as`, `external`, `group_id`)
|
||||
SELECT m.`username`, s.`address`, 0, m.`group_id` FROM `mailbox_group_member` m
|
||||
JOIN `mailbox_group_send_as` s ON s.`group_id` = m.`group_id`
|
||||
WHERE m.`group_id` = :id AND m.`username` != s.`address`", ['id' => $id]);
|
||||
}
|
||||
if ($sogo) {
|
||||
$this->rebuildSogoAcl($id, $group['active'] == 1, $members);
|
||||
}
|
||||
// Dovecot caches acl_groups (userdb) of the members, incl. the ones that just left
|
||||
if ($this->query("SELECT 1 FROM `mailbox_group_imap_grant` WHERE `group_id` = :id LIMIT 1", ['id' => $id])->fetchColumn()) {
|
||||
$this->effects['members'] = array_merge($this->effects['members'], $members, $this->removedMembers);
|
||||
}
|
||||
$this->removedMembers = [];
|
||||
$after = $visible ? $members : [];
|
||||
// Same visible address: only joined and left members are affected
|
||||
if ($before['members'] !== [] && $visible && $before['address'] === $group['address']) {
|
||||
return array_values(array_merge(array_diff($before['members'], $after), array_diff($after, $before['members'])));
|
||||
}
|
||||
return array_values(array_unique(array_merge($before['members'], $after)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every group as the IdP sync plans with it: {id, domain, name, idp_ref, template, priority, active,
|
||||
* members: username => source}
|
||||
*
|
||||
* @return array[]
|
||||
*/
|
||||
public function syncState(): array
|
||||
{
|
||||
$members = [];
|
||||
foreach ($this->query("SELECT `group_id`, `username`, `source` FROM `mailbox_group_member`")->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$members[(int)$row['group_id']][(string)$row['username']] = (string)$row['source'];
|
||||
}
|
||||
$groups = [];
|
||||
foreach ($this->query("SELECT g.`id`, g.`domain`, g.`name`, g.`idp_ref`, t.`template`, g.`priority`, g.`active`
|
||||
FROM `mailbox_group` g LEFT JOIN `templates` t ON t.`id` = g.`template_id` ORDER BY g.`id`")->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$groups[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'domain' => (string)$row['domain'],
|
||||
'name' => (string)$row['name'],
|
||||
'idp_ref' => isset($row['idp_ref']) ? (string)$row['idp_ref'] : null,
|
||||
'template' => isset($row['template']) ? (string)$row['template'] : null,
|
||||
'priority' => (int)$row['priority'],
|
||||
'active' => (int)$row['active'],
|
||||
'members' => $members[(int)$row['id']] ?? [],
|
||||
];
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/** @return int[] groups with this member, a send-as grant on this address or a SOGo grant on this mailbox */
|
||||
public function groupIdsOfAccount(string $address): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT `group_id` FROM `mailbox_group_member` WHERE `username` = :a1
|
||||
UNION SELECT `group_id` FROM `mailbox_group_send_as` WHERE `address` = :a2
|
||||
UNION SELECT `group_id` FROM `mailbox_group_sogo_grant` WHERE `owner` = :a3",
|
||||
['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** @return int[] groups with send-as grants on addresses of the domain (incl. @domain) */
|
||||
public function groupIdsWithSendAsIn(string $domain): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT DISTINCT `group_id` FROM `mailbox_group_send_as`
|
||||
WHERE SUBSTRING_INDEX(`address`, '@', -1) = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/**
|
||||
* A mailbox is deleted: its memberships and the grants on its address go, the groups are rebuilt.
|
||||
*
|
||||
* @param int[] $ids groups of the account (groupIdsOfAccount())
|
||||
* @return string[] usernames whose SOGo identities changed
|
||||
*/
|
||||
public function removeAccount(string $username, array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($username, $ids): array {
|
||||
$this->query("DELETE FROM `mailbox_group_member` WHERE `username` = :username", ['username' => $username]);
|
||||
$this->query("DELETE FROM `mailbox_group_send_as` WHERE `address` = :username", ['username' => $username]);
|
||||
return $this->rebuildAll($ids);
|
||||
});
|
||||
}
|
||||
|
||||
/** An alias was deleted: grants on its address go */
|
||||
public function removeSendAs(string $address, array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($address, $ids): array {
|
||||
$this->query("DELETE FROM `mailbox_group_send_as` WHERE `address` = :address", ['address' => $address]);
|
||||
return $this->rebuildAll($ids);
|
||||
});
|
||||
}
|
||||
|
||||
/** A domain or alias domain is deleted: grants on its addresses go */
|
||||
public function removeSendAsIn(string $domain, array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($domain, $ids): array {
|
||||
$this->query("DELETE FROM `mailbox_group_send_as` WHERE SUBSTRING_INDEX(`address`, '@', -1) = :domain", ['domain' => $domain]);
|
||||
return $this->rebuildAll($ids);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Account or alias address renamed: members and grants follow. Explicit updates, because the mailbox rename
|
||||
* runs with FOREIGN_KEY_CHECKS = 0; joins the caller's transaction.
|
||||
*/
|
||||
public function renameAccount(string $old, string $new, array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($old, $new, $ids): array {
|
||||
$this->query("UPDATE `mailbox_group_member` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]);
|
||||
$this->query("UPDATE `mailbox_group_send_as` SET `address` = :new WHERE `address` = :old", ['new' => $new, 'old' => $old]);
|
||||
return $this->rebuildAll($ids, false);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return int[] groups with SOGo grants */
|
||||
public function groupIdsWithSogoGrants(): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT DISTINCT `group_id` FROM `mailbox_group_sogo_grant`")->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP grants Dovecot has to apply: pending, failed and removing ones, with $all also applied ones (new
|
||||
* top-level folders); of one group or all.
|
||||
*
|
||||
* @return array[] rows of mailbox_group_imap_grant
|
||||
*/
|
||||
public function imapGrantsToApply(?int $groupId, bool $all): array
|
||||
{
|
||||
$where = $all ? '1=1' : "`state` != 'applied'";
|
||||
$params = [];
|
||||
if ($groupId !== null) {
|
||||
$where .= " AND `group_id` = :id";
|
||||
$params['id'] = $groupId;
|
||||
}
|
||||
return $this->query("SELECT * FROM `mailbox_group_imap_grant` WHERE $where ORDER BY `id`", $params)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function setImapState(int $grantId, string $state, ?string $error): void
|
||||
{
|
||||
$this->query("UPDATE `mailbox_group_imap_grant` SET `state` = :state, `last_error` = :error WHERE `id` = :id",
|
||||
['state' => $state, 'error' => $error, 'id' => $grantId]);
|
||||
}
|
||||
|
||||
public function deleteImapGrant(int $grantId): void
|
||||
{
|
||||
$this->query("DELETE FROM `mailbox_group_imap_grant` WHERE `id` = :id", ['id' => $grantId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the sogo_acl rows of grants that are gone, e.g. by FK cascades of groups, owners and domains.
|
||||
* Joins the caller's transaction.
|
||||
*/
|
||||
public function cleanupSogoAcl(): void
|
||||
{
|
||||
$this->transaction(function (): void {
|
||||
$orphans = $this->query("SELECT l.`acl_id` FROM `mailbox_group_sogo_acl` l
|
||||
LEFT JOIN `mailbox_group_sogo_grant` g ON g.`id` = l.`grant_id`
|
||||
WHERE g.`id` IS NULL")->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($orphans as $aclId) {
|
||||
$this->deleteSogoAcl((int)$aclId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Members to subscribe to grants with `subscribe` (kind imap or sogo): applied grants of active groups, members
|
||||
* without a subscription by mailcow yet (a member that removes it keeps it removed).
|
||||
*
|
||||
* @return array[] {grant_id, owner, folder, usernames}
|
||||
*/
|
||||
public function missingSubscriptions(string $kind): array
|
||||
{
|
||||
$config = self::SUBSCRIPTION_GRANTS[$kind];
|
||||
$grants = [];
|
||||
foreach ($this->query("SELECT g.`id`, g.`owner`, {$config['folder']} AS `folder`, m.`username`
|
||||
FROM `{$config['table']}` g
|
||||
JOIN `mailbox_group` mg ON mg.`id` = g.`group_id` AND mg.`active` = 1
|
||||
JOIN `mailbox_group_member` m ON m.`group_id` = g.`group_id` AND m.`username` != g.`owner`
|
||||
LEFT JOIN `mailbox_group_subscription` s ON s.`kind` = :kind AND s.`grant_id` = g.`id` AND s.`username` = m.`username`
|
||||
WHERE g.`subscribe` = 1 AND g.`state` = 'applied' AND s.`id` IS NULL
|
||||
ORDER BY g.`id`, m.`username`", ['kind' => $kind])->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$grants[$row['id']] ??= ['grant_id' => (int)$row['id'], 'owner' => (string)$row['owner'], 'folder' => (string)$row['folder'], 'usernames' => []];
|
||||
$grants[$row['id']]['usernames'][] = (string)$row['username'];
|
||||
}
|
||||
return array_values($grants);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions by mailcow whose member lost the access: grant, membership or group gone or inactive.
|
||||
* `covered`: another group still grants the member the same folder, so the subscription stays.
|
||||
*
|
||||
* @return array[] rows of mailbox_group_subscription with member_exists, owner_exists, covered
|
||||
*/
|
||||
public function staleSubscriptions(string $kind): array
|
||||
{
|
||||
$config = self::SUBSCRIPTION_GRANTS[$kind];
|
||||
return $this->query("SELECT s.`id`, s.`username`, s.`owner`, s.`folder`,
|
||||
EXISTS(SELECT 1 FROM `mailbox` WHERE `username` = s.`username`) AS `member_exists`,
|
||||
EXISTS(SELECT 1 FROM `mailbox` WHERE `username` = s.`owner`) AS `owner_exists`,
|
||||
EXISTS(SELECT 1 FROM `mailbox_group_subscription` s2
|
||||
JOIN `{$config['table']}` g2 ON g2.`id` = s2.`grant_id`{$config['valid']}
|
||||
JOIN `mailbox_group` mg2 ON mg2.`id` = g2.`group_id` AND mg2.`active` = 1
|
||||
JOIN `mailbox_group_member` m2 ON m2.`group_id` = g2.`group_id` AND m2.`username` = s2.`username`
|
||||
WHERE s2.`kind` = s.`kind` AND s2.`id` != s.`id` AND s2.`username` = s.`username` AND s2.`owner` = s.`owner`
|
||||
AND s2.`folder` = s.`folder`) AS `covered`
|
||||
FROM `mailbox_group_subscription` s
|
||||
LEFT JOIN `{$config['table']}` g ON g.`id` = s.`grant_id`
|
||||
LEFT JOIN `mailbox_group` mg ON mg.`id` = g.`group_id` AND mg.`active` = 1
|
||||
LEFT JOIN `mailbox_group_member` m ON m.`group_id` = g.`group_id` AND m.`username` = s.`username`
|
||||
WHERE s.`kind` = :kind AND (g.`id` IS NULL OR mg.`id` IS NULL OR m.`username` IS NULL{$config['gone']})
|
||||
ORDER BY s.`id`", ['kind' => $kind])->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function addSubscriptions(string $kind, int $grantId, string $owner, string $folder, array $usernames): void
|
||||
{
|
||||
foreach ($usernames as $username) {
|
||||
$this->query("INSERT IGNORE INTO `mailbox_group_subscription` (`kind`, `grant_id`, `username`, `owner`, `folder`)
|
||||
VALUES (:kind, :grant, :username, :owner, :folder)",
|
||||
['kind' => $kind, 'grant' => $grantId, 'username' => $username, 'owner' => $owner, 'folder' => $folder]);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteSubscription(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `mailbox_group_subscription` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owners of pending SOGo grants on their personal folder that SOGo knows (active mailboxes), of one group or all.
|
||||
* Their folders do not exist because they never logged in to SOGo, e.g. shared mailboxes.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function pendingSogoOwners(?int $groupId): array
|
||||
{
|
||||
$params = [];
|
||||
$where = '';
|
||||
if ($groupId !== null) {
|
||||
$where = " AND g.`group_id` = :id";
|
||||
$params['id'] = $groupId;
|
||||
}
|
||||
return $this->query("SELECT DISTINCT g.`owner` FROM `mailbox_group_sogo_grant` g
|
||||
JOIN `mailbox` mb ON mb.`username` = g.`owner` AND mb.`active` = '1' AND mb.`kind` = ''
|
||||
WHERE g.`state` = 'pending' AND g.`folder` = 'personal'$where ORDER BY g.`owner`", $params)->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side effects of the writes since the last call, for ChangeEffects after the commit.
|
||||
*
|
||||
* @return array{members: string[], sogo_acl: bool}
|
||||
*/
|
||||
public function takeEffects(): array
|
||||
{
|
||||
$effects = $this->effects;
|
||||
$effects['members'] = array_values(array_unique($effects['members']));
|
||||
$this->effects = ['members' => [], 'sogo_acl' => false];
|
||||
return $effects;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function rebuildAll(array $ids, bool $sogo = true): array
|
||||
{
|
||||
$users = [];
|
||||
foreach (array_unique($ids) as $id) {
|
||||
$users = array_merge($users, $this->rebuild((int)$id, $sogo));
|
||||
}
|
||||
return array_values(array_unique($users));
|
||||
}
|
||||
|
||||
/**
|
||||
* sogo_acl rows of the group's SOGo grants: one row per member (except the owner) and role while the group is
|
||||
* active. Only rows of the ledger are ever deleted, so shares made in SOGo stay. A grant on a folder that does
|
||||
* not exist yet (the owner never opened SOGo) stays pending.
|
||||
*/
|
||||
private function rebuildSogoAcl(int $id, bool $active, array $members): void
|
||||
{
|
||||
$this->cleanupSogoAcl();
|
||||
foreach ($this->query("SELECT * FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $grant) {
|
||||
$object = '/' . $grant['owner'] . '/' . $grant['folder_type'] . '/' . $grant['folder'];
|
||||
$folderId = $this->query("SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path` = :path",
|
||||
['path' => '/Users' . $object])->fetchColumn();
|
||||
$wanted = [];
|
||||
if ($folderId !== false && $active) {
|
||||
foreach ($members as $username) {
|
||||
foreach (($username === $grant['owner']) ? [] : self::SOGO_ROLES[$grant['folder_type']][$grant['access']] as $role) {
|
||||
$wanted[$username . '|' . $role] = [$username, $role];
|
||||
}
|
||||
}
|
||||
}
|
||||
$rows = $this->query("SELECT l.`acl_id`, l.`c_uid`, l.`c_role`, a.`c_folder_id`, a.`c_object` FROM `mailbox_group_sogo_acl` l
|
||||
LEFT JOIN `sogo_acl` a ON a.`id` = l.`acl_id`
|
||||
WHERE l.`grant_id` = :id", ['id' => $grant['id']])->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['c_uid'] . '|' . $row['c_role'];
|
||||
if (isset($wanted[$key]) && (string)$row['c_folder_id'] === (string)$folderId && $row['c_object'] === $object) {
|
||||
unset($wanted[$key]);
|
||||
continue;
|
||||
}
|
||||
$this->deleteSogoAcl((int)$row['acl_id']);
|
||||
}
|
||||
foreach ($wanted as [$username, $role]) {
|
||||
$aclId = $this->insertRow('sogo_acl', ['c_folder_id' => $folderId, 'c_object' => $object, 'c_uid' => $username, 'c_role' => $role]);
|
||||
$this->query("INSERT INTO `mailbox_group_sogo_acl` (`acl_id`, `grant_id`, `c_uid`, `c_role`) VALUES (:acl, :grant, :uid, :role)",
|
||||
['acl' => $aclId, 'grant' => $grant['id'], 'uid' => $username, 'role' => $role]);
|
||||
$this->effects['sogo_acl'] = true;
|
||||
}
|
||||
$state = ($folderId === false) ? 'pending' : 'applied';
|
||||
if ($grant['state'] !== $state) {
|
||||
$this->query("UPDATE `mailbox_group_sogo_grant` SET `state` = :state, `last_error` = :error WHERE `id` = :id",
|
||||
['state' => $state, 'error' => ($folderId === false) ? 'folder_missing' : null, 'id' => $grant['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteSogoAcl(int $aclId): void
|
||||
{
|
||||
$this->query("DELETE FROM `sogo_acl` WHERE `id` = :id", ['id' => $aclId]);
|
||||
$this->query("DELETE FROM `mailbox_group_sogo_acl` WHERE `acl_id` = :id", ['id' => $aclId]);
|
||||
$this->effects['sogo_acl'] = true;
|
||||
}
|
||||
|
||||
private function fetch(string $where, array $params): ?Group
|
||||
{
|
||||
$row = $this->query("SELECT g.*, t.`template` AS `template_name` FROM `mailbox_group` g
|
||||
LEFT JOIN `templates` t ON t.`id` = g.`template_id`
|
||||
WHERE g.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$group = Group::fromRow($row);
|
||||
$id = ['id' => $group->id];
|
||||
$group->members = $this->query("SELECT `username`, `source` FROM `mailbox_group_member` WHERE `group_id` = :id ORDER BY `username`",
|
||||
$id)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$group->sendAs = $this->query("SELECT `address` FROM `mailbox_group_send_as` WHERE `group_id` = :id ORDER BY `address`",
|
||||
$id)->fetchAll(PDO::FETCH_COLUMN);
|
||||
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
||||
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
|
||||
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
||||
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
||||
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
|
||||
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
||||
return $group;
|
||||
}
|
||||
|
||||
/** Members (username => source) as in the model; changed sources are updated, created stays */
|
||||
private function writeMembers(Group $group): void
|
||||
{
|
||||
$id = (int)$group->id;
|
||||
$stored = $this->query("SELECT `username`, `source` FROM `mailbox_group_member` WHERE `group_id` = :id", ['id' => $id])
|
||||
->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
foreach (array_diff_key($stored, $group->members) as $username => $source) {
|
||||
$this->query("DELETE FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username",
|
||||
['id' => $id, 'username' => $username]);
|
||||
$this->removedMembers[] = (string)$username;
|
||||
}
|
||||
foreach ($group->members as $username => $source) {
|
||||
if (!isset($stored[$username])) {
|
||||
$this->query("INSERT INTO `mailbox_group_member` (`group_id`, `username`, `source`) VALUES (:id, :username, :source)",
|
||||
['id' => $id, 'username' => $username, 'source' => $source]);
|
||||
} elseif ($stored[$username] !== $source) {
|
||||
$this->query("UPDATE `mailbox_group_member` SET `source` = :source WHERE `group_id` = :id AND `username` = :username",
|
||||
['source' => $source, 'id' => $id, 'username' => $username]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP grants: new ones are pending, removed ones are set to removing until Dovecot dropped the ACL
|
||||
* (SharingService). SOGo grants are replaced; rebuild() maintains their sogo_acl rows.
|
||||
*/
|
||||
private function writeGrants(Group $group): void
|
||||
{
|
||||
$id = (int)$group->id;
|
||||
$stored = [];
|
||||
foreach ($this->query("SELECT `id`, `owner`, `rights`, `subscribe`, `state` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id",
|
||||
['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$stored[(string)$row['owner']] = $row;
|
||||
}
|
||||
$wanted = [];
|
||||
foreach ($group->imapGrants as $grant) {
|
||||
$wanted[(string)$grant['owner']] = $grant;
|
||||
}
|
||||
foreach ($stored as $owner => $row) {
|
||||
if (!isset($wanted[$owner]) && $row['state'] !== 'removing') {
|
||||
$this->setImapState((int)$row['id'], 'removing', null);
|
||||
}
|
||||
}
|
||||
foreach ($wanted as $owner => $grant) {
|
||||
$row = $stored[$owner] ?? null;
|
||||
$subscribe = (int)($grant['subscribe'] ?? 0);
|
||||
if ($row === null) {
|
||||
$this->insertRow('mailbox_group_imap_grant', ['group_id' => $id, 'owner' => $owner, 'rights' => $grant['rights'],
|
||||
'subscribe' => $subscribe, 'state' => 'pending']);
|
||||
continue;
|
||||
}
|
||||
if ($row['rights'] !== $grant['rights'] || $row['state'] === 'removing') {
|
||||
$this->query("UPDATE `mailbox_group_imap_grant` SET `rights` = :rights, `state` = 'pending', `last_error` = NULL WHERE `id` = :id",
|
||||
['rights' => $grant['rights'], 'id' => $row['id']]);
|
||||
}
|
||||
if ((int)$row['subscribe'] !== $subscribe) {
|
||||
$this->query("UPDATE `mailbox_group_imap_grant` SET `subscribe` = :subscribe WHERE `id` = :id",
|
||||
['subscribe' => $subscribe, 'id' => $row['id']]);
|
||||
}
|
||||
}
|
||||
$stored = [];
|
||||
foreach ($this->query("SELECT `id`, `owner`, `folder_type`, `folder`, `access`, `subscribe` FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id",
|
||||
['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$stored[$row['owner'] . '|' . $row['folder_type'] . '|' . $row['folder']] = $row;
|
||||
}
|
||||
$wanted = [];
|
||||
foreach ($group->sogoGrants as $grant) {
|
||||
$wanted[$grant['owner'] . '|' . $grant['type'] . '|' . $grant['folder']] = $grant;
|
||||
}
|
||||
foreach (array_diff_key($stored, $wanted) as $row) {
|
||||
$this->query("DELETE FROM `mailbox_group_sogo_grant` WHERE `id` = :id", ['id' => $row['id']]);
|
||||
}
|
||||
foreach ($wanted as $key => $grant) {
|
||||
$subscribe = (int)($grant['subscribe'] ?? 0);
|
||||
if (!isset($stored[$key])) {
|
||||
$this->insertRow('mailbox_group_sogo_grant', ['group_id' => $id, 'owner' => $grant['owner'], 'folder_type' => $grant['type'],
|
||||
'folder' => $grant['folder'], 'access' => $grant['access'], 'subscribe' => $subscribe, 'state' => 'pending']);
|
||||
} elseif ($stored[$key]['access'] !== $grant['access'] || (int)$stored[$key]['subscribe'] !== $subscribe) {
|
||||
$this->query("UPDATE `mailbox_group_sogo_grant` SET `access` = :access, `subscribe` = :subscribe WHERE `id` = :id",
|
||||
['access' => $grant['access'], 'subscribe' => $subscribe, 'id' => $stored[$key]['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function writeSendAs(Group $group): void
|
||||
{
|
||||
$id = ['id' => $group->id];
|
||||
$this->query("DELETE FROM `mailbox_group_send_as` WHERE `group_id` = :id", $id);
|
||||
foreach ($group->sendAs as $address) {
|
||||
$this->query("INSERT INTO `mailbox_group_send_as` (`group_id`, `address`) VALUES (:id, :address)", $id + ['address' => $address]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's alias row as SOGo sees it: visible = active and sogo_visible (see the grouped_mail_aliases view).
|
||||
*
|
||||
* @return array{address: ?string, members: string[]} members only when visible
|
||||
*/
|
||||
private function aliasState(int $id): array
|
||||
{
|
||||
$row = $this->query("SELECT `address`, `goto`, `active`, `sogo_visible` FROM `alias` WHERE `group_id` = :id",
|
||||
['id' => $id])->fetch(PDO::FETCH_ASSOC);
|
||||
$visible = $row && $row['active'] == 1 && $row['sogo_visible'] == 1;
|
||||
return ['address' => $row['address'] ?? null, 'members' => $visible ? explode(',', (string)$row['goto']) : []];
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE fragment over `mailbox_group g` that limits rows to what the caller may see, and its parameters.
|
||||
*
|
||||
* @return array{0: string, 1: array}
|
||||
*/
|
||||
private function visibility(Context $context): array
|
||||
{
|
||||
if ($context->hasAdminRights()) {
|
||||
return ['1=1', []];
|
||||
}
|
||||
if ($context->isDomainAdmin()) {
|
||||
return ["g.`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||
['vis_da' => $context->username]];
|
||||
}
|
||||
return ['0=1', []];
|
||||
}
|
||||
|
||||
private function values(Group $group): array
|
||||
{
|
||||
return [
|
||||
'domain' => $group->domain,
|
||||
'name' => $group->name,
|
||||
'description' => $group->description,
|
||||
'address' => $group->address,
|
||||
'sender_allowed' => $group->senderAllowed,
|
||||
'sogo_visible' => $group->sogoVisible,
|
||||
'internal' => $group->internal,
|
||||
'template_id' => $group->templateId,
|
||||
'priority' => $group->priority,
|
||||
'idp_ref' => $group->idpRef,
|
||||
'active' => $group->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Read-only queries on tables of the legacy code (domains, mailboxes, aliases, templates) and the group
|
||||
* memberships the template logic needs.
|
||||
*/
|
||||
class LookupRepository extends Repository
|
||||
{
|
||||
/** A primary domain (groups never belong to alias domains) */
|
||||
public function isDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
public function isDomainOrAliasDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** An alias or mailbox address (mailboxes have an alias row of their own) */
|
||||
public function aliasExists(string $address): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Used by an alias other than the group's own row, by a mailbox or resource, or as spam alias */
|
||||
public function addressTaken(string $address, ?int $groupId): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :a1 AND (`group_id` IS NULL OR `group_id` != :id)
|
||||
UNION SELECT 1 FROM `mailbox` WHERE `username` = :a2
|
||||
UNION SELECT 1 FROM `spamalias` WHERE `address` = :a3",
|
||||
['a1' => $address, 'id' => $groupId ?? 0, 'a2' => $address, 'a3' => $address])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Aliases the domain may still create (same count as mailbox('get', 'domain_details')) */
|
||||
public function aliasesLeft(string $domain): int
|
||||
{
|
||||
$max = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
$used = $this->query("SELECT COUNT(`address`) FROM `alias`
|
||||
WHERE (`domain` = :d1 OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :d2))
|
||||
AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
return (int)$max - (int)$used;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $usernames
|
||||
* @return string[] the given usernames that are regular mailboxes (no resources)
|
||||
*/
|
||||
public function regularMailboxes(array $usernames): array
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in('u', $usernames);
|
||||
return $this->query("SELECT `username` FROM `mailbox` WHERE `kind` = '' AND `username` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/** Name of a mailbox template, null if there is none with this id */
|
||||
public function templateName(int $id): ?string
|
||||
{
|
||||
$name = $this->query("SELECT `template` FROM `templates` WHERE `id` = :id AND `type` = 'mailbox'", ['id' => $id])->fetchColumn();
|
||||
return ($name === false) ? null : (string)$name;
|
||||
}
|
||||
|
||||
/** @return array<string, string> username => full name */
|
||||
public function mailboxNames(array $usernames): array
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in('u', $usernames);
|
||||
return $this->query("SELECT `username`, `name` FROM `mailbox` WHERE `username` IN ($in)", $params)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template of the active group with the highest priority (lowest id on ties) per member.
|
||||
*
|
||||
* @return array<string, ?string> username => template name, null without group template
|
||||
*/
|
||||
public function effectiveTemplates(array $usernames): array
|
||||
{
|
||||
$result = array_fill_keys(array_map('strval', $usernames), null);
|
||||
if (empty($usernames)) {
|
||||
return $result;
|
||||
}
|
||||
[$in, $params] = self::in('u', $usernames);
|
||||
$rows = $this->query("SELECT m.`username`, t.`template` FROM `mailbox_group_member` m
|
||||
JOIN `mailbox_group` g ON g.`id` = m.`group_id` AND g.`active` = '1'
|
||||
JOIN `templates` t ON t.`id` = g.`template_id` AND t.`type` = 'mailbox'
|
||||
WHERE m.`username` IN ($in)
|
||||
ORDER BY m.`username`, g.`priority` DESC, g.`id`", $params)->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$result[$row['username']] ??= $row['template'];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return string[] members of active groups bound to the template */
|
||||
public function membersWithTemplate(int $templateId): array
|
||||
{
|
||||
return $this->query("SELECT DISTINCT m.`username` FROM `mailbox_group_member` m
|
||||
JOIN `mailbox_group` g ON g.`id` = m.`group_id` AND g.`active` = '1'
|
||||
WHERE g.`template_id` = :id", ['id' => $templateId])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/** Groups of a mailbox with its membership source */
|
||||
public function membershipsOf(string $username): array
|
||||
{
|
||||
return $this->query("SELECT g.`id`, g.`domain`, g.`name`, g.`address`, g.`sender_allowed`, g.`active`, g.`priority`,
|
||||
t.`template`, m.`source` FROM `mailbox_group_member` m
|
||||
JOIN `mailbox_group` g ON g.`id` = m.`group_id`
|
||||
LEFT JOIN `templates` t ON t.`id` = g.`template_id`
|
||||
WHERE m.`username` = :username ORDER BY g.`domain`, g.`name`", ['username' => $username])->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/** @return string[] addresses a mailbox may send as through its active groups */
|
||||
public function sendAsOf(string $username): array
|
||||
{
|
||||
return $this->query("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :u1 AND `group_id` IS NOT NULL
|
||||
UNION SELECT g.`address` FROM `mailbox_group_member` m JOIN `mailbox_group` g ON g.`id` = m.`group_id`
|
||||
WHERE m.`username` = :u2 AND g.`active` = '1' AND g.`sender_allowed` = '1' AND g.`address` IS NOT NULL
|
||||
ORDER BY 1", ['u1' => $username, 'u2' => $username])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* IN list with one placeholder per value (native prepares allow a placeholder only once).
|
||||
*
|
||||
* @return array{0: string, 1: array}
|
||||
*/
|
||||
private static function in(string $prefix, array $values): array
|
||||
{
|
||||
$names = [];
|
||||
$params = [];
|
||||
foreach (array_values($values) as $i => $value) {
|
||||
$names[] = ':' . $prefix . $i;
|
||||
$params[$prefix . $i] = (string)$value;
|
||||
}
|
||||
return [implode(', ', $names), $params];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
|
||||
/**
|
||||
* Keeps groups consistent with account changes made by the legacy code (functions.mailbox.inc.php).
|
||||
* No permission checks: the legacy caller already checked them.
|
||||
*/
|
||||
class AccountHooks
|
||||
{
|
||||
public function __construct(private GroupRepository $repo, private ChangeEffects $effects)
|
||||
{
|
||||
}
|
||||
|
||||
/** Before a mailbox is deleted: its memberships and the grants on its address go */
|
||||
public function onMailboxDeleted(string $username): void
|
||||
{
|
||||
$ids = $this->repo->groupIdsOfAccount($username);
|
||||
if (!empty($ids)) {
|
||||
// The deleted mailbox itself needs no SOGo refresh
|
||||
$this->effects->apply(array_diff($this->repo->removeAccount($username, $ids), [$username]), []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside the legacy mailbox rename transaction (FOREIGN_KEY_CHECKS = 0): members and grants follow.
|
||||
* Must not catch, so the caller can roll back. The legacy rename refreshes SOGo itself.
|
||||
*/
|
||||
public function onMailboxRenamed(string $old, string $new): void
|
||||
{
|
||||
$ids = $this->repo->groupIdsOfAccount($old);
|
||||
if (!empty($ids)) {
|
||||
$this->repo->renameAccount($old, $new, $ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a domain or alias domain was deleted: grants on its addresses go. Its groups went via FK cascade,
|
||||
* the sogo_acl rows of their SOGo grants go here.
|
||||
*/
|
||||
public function onDomainDeleted(string $domain): void
|
||||
{
|
||||
$ids = $this->repo->groupIdsWithSendAsIn($domain);
|
||||
$users = empty($ids) ? [] : $this->repo->removeSendAsIn($domain, $ids);
|
||||
$this->repo->cleanupSogoAcl();
|
||||
$this->effects->apply($users, []);
|
||||
}
|
||||
|
||||
/** An alias was deleted: grants on its address go */
|
||||
public function onAddressDeleted(string $address): void
|
||||
{
|
||||
$ids = $this->repo->groupIdsOfAccount($address);
|
||||
if (!empty($ids)) {
|
||||
$this->effects->apply($this->repo->removeSendAs($address, $ids), []);
|
||||
}
|
||||
}
|
||||
|
||||
/** An alias address changed: grants follow */
|
||||
public function onAddressRenamed(string $old, string $new): void
|
||||
{
|
||||
$ids = $this->repo->groupIdsOfAccount($old);
|
||||
if (!empty($ids) && $old !== $new) {
|
||||
$this->effects->apply($this->repo->renameAccount($old, $new, $ids), []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\Dovecot\Service\DovecotService;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\Sogo\Service\SogoService;
|
||||
|
||||
/**
|
||||
* What follows a committed group write: SOGo identities of the affected members, group templates and the caches
|
||||
* of SOGo (ACLs) and Dovecot (acl_groups). Runs after the commit; failures become warnings and never roll the
|
||||
* write back.
|
||||
*/
|
||||
class ChangeEffects
|
||||
{
|
||||
public function __construct(private TemplateService $templates, private SogoService $sogo, private GroupRepository $repo,
|
||||
private DovecotService $dovecot)
|
||||
{
|
||||
}
|
||||
|
||||
/** Effective templates of the mailboxes a write may touch, taken before the write */
|
||||
public function snapshot(array $usernames): array
|
||||
{
|
||||
return $this->templates->effective($usernames);
|
||||
}
|
||||
|
||||
/** @return array[] warnings (msg values) */
|
||||
public function apply(array $sogoUsers, array $snapshot): array
|
||||
{
|
||||
$this->sogo->refreshStaticView($sogoUsers);
|
||||
$effects = $this->repo->takeEffects();
|
||||
if ($effects['sogo_acl']) {
|
||||
$this->sogo->flushCache();
|
||||
}
|
||||
$this->dovecot->flushAuthCache($effects['members']);
|
||||
return $this->templates->applyChanged($snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Use cases and rules for mailbox groups.
|
||||
*
|
||||
* Read: admins all groups (also read-only API keys); domain admins with the `mailbox_groups` ACL the groups of
|
||||
* their domains; users only their own memberships (membershipsOf()).
|
||||
* Edit: admins; domain admins with the ACL for groups of their domains, as long as every send-as grant of the
|
||||
* group is inside their domains. Template, priority and IdP link are set by admins only.
|
||||
* Members: regular mailboxes the editor may access. IdP members and members outside the editor's scope are kept
|
||||
* when the member list is replaced; IdP members can only be removed by the IdP sync.
|
||||
*/
|
||||
class GroupService
|
||||
{
|
||||
/** Postfix expands at most this many recipients per address (virtual_alias_expansion_limit) */
|
||||
private const MAX_RECIPIENTS = 1000;
|
||||
|
||||
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private TemplateService $templates,
|
||||
private ChangeEffects $effects, private SharingService $sharing, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights()
|
||||
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('mailbox_groups'));
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless the caller may edit groups at all */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(int $id): ?Group
|
||||
{
|
||||
return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null;
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
public function visibleIds(array $domains = []): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Group $group): bool
|
||||
{
|
||||
if ($this->context->isReadOnlyApi() || !$this->canRead()) {
|
||||
return false;
|
||||
}
|
||||
if ($this->context->hasAdminRights()) {
|
||||
return true;
|
||||
}
|
||||
if (!$this->context->canAccessDomain($group->domain)) {
|
||||
return false;
|
||||
}
|
||||
// Grants on foreign domains or mailboxes make the group read-only for a domain admin
|
||||
foreach ($group->sendAs as $address) {
|
||||
if (!$this->context->canAccessDomain(self::domainOf($address))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (array_merge($group->imapGrants, $group->sogoGrants) as $grant) {
|
||||
if (!$this->context->canAccessMailbox((string)$grant['owner'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function toArray(Group $group): array
|
||||
{
|
||||
return $group->toArray($this->canEdit($group));
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function create(array $input): Group
|
||||
{
|
||||
$this->requireEditor();
|
||||
$group = new Group();
|
||||
$group->domain = $this->domainFor($input['domain'] ?? null);
|
||||
$this->apply($group, $input, null);
|
||||
$snapshot = $this->effects->snapshot($group->usernames());
|
||||
$sogo = $this->repo->insert($group);
|
||||
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
|
||||
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
|
||||
return $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a group; attributes missing in $input keep their value. `reapply_template` (admin) applies the
|
||||
* effective templates to all members again.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function update(int $id, array $input): Group
|
||||
{
|
||||
$this->requireEditor();
|
||||
$current = $this->editable($id);
|
||||
$group = clone $current;
|
||||
$this->apply($group, $input, $current);
|
||||
$snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames()));
|
||||
$sogo = $this->repo->update($group);
|
||||
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
|
||||
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
|
||||
if ($this->context->hasAdminRights() && self::flag($input['reapply_template'] ?? 0) === 1) {
|
||||
$group->warnings = array_merge($group->warnings, $this->templates->applyTo($group->usernames()));
|
||||
}
|
||||
return $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a group and returns it. Members keep their settings; another group's template may now apply.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function delete(int $id): Group
|
||||
{
|
||||
$this->requireEditor();
|
||||
$group = $this->editable($id);
|
||||
$snapshot = $this->effects->snapshot($group->usernames());
|
||||
$this->sharing->removeImap($group);
|
||||
$sogo = $this->repo->delete($id);
|
||||
$group->warnings = $this->effects->apply($sogo, $snapshot);
|
||||
return $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups of a mailbox, its effective group template and the addresses it may send as through groups.
|
||||
* For the mailbox itself and for admins/domain admins with access to it.
|
||||
*/
|
||||
public function membershipsOf(string $username): array
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$own = ($this->context->isUser() && $this->context->username === $username);
|
||||
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'groups' => $this->lookup->membershipsOf($username),
|
||||
'template' => $this->templates->effective([$username])[$username] ?? null,
|
||||
'send_as' => $this->lookup->sendAsOf($username),
|
||||
];
|
||||
}
|
||||
|
||||
/** @throws AccessDenied when the caller cannot see or edit the group */
|
||||
private function editable(int $id): Group
|
||||
{
|
||||
$group = $this->get($id);
|
||||
if ($group === null || !$this->canEdit($group)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $group;
|
||||
}
|
||||
|
||||
/** @throws UserError domain_not_found, access_denied */
|
||||
private function domainFor(mixed $value): string
|
||||
{
|
||||
$domain = Input::text($value);
|
||||
$domain = ($domain === null || $domain === '') ? '' : (string)idn_to_ascii(strtolower($domain), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
if ($domain === '' || !$this->lookup->isDomain($domain)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit rules shared by create() and update(): attributes missing in $input keep the value of $group,
|
||||
* a new group starts with the model defaults. Everything is validated before anything is written.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function apply(Group $group, array $input, ?Group $current): void
|
||||
{
|
||||
$name = isset($input['name']) ? Input::text($input['name']) : $group->name;
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100) {
|
||||
throw new UserError('group_name_invalid');
|
||||
}
|
||||
$description = isset($input['description']) ? Input::text($input['description']) : $group->description;
|
||||
if ($description === null || mb_strlen($description) > 255) {
|
||||
throw new UserError('group_description_invalid');
|
||||
}
|
||||
if ($this->repo->nameExists($group->domain, $name, $group->id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||
}
|
||||
$group->name = $name;
|
||||
$group->description = $description;
|
||||
foreach (['sender_allowed' => 'senderAllowed', 'sogo_visible' => 'sogoVisible', 'internal' => 'internal', 'active' => 'active'] as $key => $property) {
|
||||
if (isset($input[$key])) {
|
||||
$group->$property = self::flag($input[$key]);
|
||||
}
|
||||
}
|
||||
$this->applyAddress($group, $input, $current);
|
||||
$this->applyMembers($group, $input);
|
||||
$this->applySendAs($group, $input);
|
||||
$this->applyGrants($group, $input);
|
||||
if ($this->context->hasAdminRights()) {
|
||||
$this->applyAdminFields($group, $input);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function applyAddress(Group $group, array $input, ?Group $current): void
|
||||
{
|
||||
if (array_key_exists('address', $input)) {
|
||||
$value = Input::text($input['address']);
|
||||
} elseif (array_key_exists('local_part', $input)) {
|
||||
$local = Input::text($input['local_part']);
|
||||
$value = ($local === null || $local === '') ? $local : $local . '@' . $group->domain;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if ($value === '') {
|
||||
$group->address = null;
|
||||
return;
|
||||
}
|
||||
$address = ($value === null) ? null : self::normalizeAddress($value);
|
||||
if ($address === null || self::domainOf($address) !== $group->domain) {
|
||||
throw new UserError('group_address_invalid', [htmlspecialchars((string)$value)]);
|
||||
}
|
||||
$group->address = $address;
|
||||
if ($address === $current?->address) {
|
||||
return;
|
||||
}
|
||||
if ($this->lookup->addressTaken($address, $group->id)) {
|
||||
throw new UserError('is_alias_or_mailbox', [htmlspecialchars($address)]);
|
||||
}
|
||||
// A group with an address counts like an alias of the domain
|
||||
if ($current?->address === null && $this->lookup->aliasesLeft($group->domain) < 1) {
|
||||
throw new UserError('max_alias_exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `members` replaces the manual members the caller may access, `add_members` / `remove_members` change them.
|
||||
*
|
||||
* @throws UserError group_member_invalid, group_member_idp, access_denied
|
||||
*/
|
||||
private function applyMembers(Group $group, array $input): void
|
||||
{
|
||||
$members = $group->members;
|
||||
$add = [];
|
||||
if (array_key_exists('members', $input)) {
|
||||
$add = self::cleanList($input['members']);
|
||||
foreach ($members as $username => $source) {
|
||||
if ($source === Group::SOURCE_MANUAL && !in_array((string)$username, $add, true) && $this->context->canAccessMailbox((string)$username)) {
|
||||
unset($members[$username]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$add = array_merge($add, self::cleanList($input['add_members'] ?? []));
|
||||
foreach (self::cleanList($input['remove_members'] ?? []) as $username) {
|
||||
if (!isset($members[$username])) {
|
||||
continue;
|
||||
}
|
||||
if ($members[$username] === Group::SOURCE_IDP) {
|
||||
throw new UserError('group_member_idp', [htmlspecialchars($username)]);
|
||||
}
|
||||
if (!$this->context->canAccessMailbox($username)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
unset($members[$username]);
|
||||
}
|
||||
$new = array_values(array_diff(array_unique($add), array_map('strval', array_keys($members))));
|
||||
$regular = $this->lookup->regularMailboxes($new);
|
||||
foreach ($new as $username) {
|
||||
if (!in_array($username, $regular, true)) {
|
||||
throw new UserError('group_member_invalid', [htmlspecialchars($username)]);
|
||||
}
|
||||
if (!$this->context->canAccessMailbox($username)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$members[$username] = Group::SOURCE_MANUAL;
|
||||
}
|
||||
ksort($members);
|
||||
$group->members = $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Addresses (mailboxes or aliases) and @domain the members may send as; the editor needs access to their domains.
|
||||
*
|
||||
* @throws UserError group_send_as_invalid, access_denied
|
||||
*/
|
||||
private function applySendAs(Group $group, array $input): void
|
||||
{
|
||||
if (!array_key_exists('send_as', $input)) {
|
||||
return;
|
||||
}
|
||||
$sendAs = [];
|
||||
foreach (self::cleanList($input['send_as']) as $value) {
|
||||
if (str_starts_with($value, '@')) {
|
||||
$domain = (string)idn_to_ascii(substr($value, 1), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
$address = '@' . $domain;
|
||||
$valid = ($domain !== '' && $this->lookup->isDomainOrAliasDomain($domain));
|
||||
} else {
|
||||
$address = self::normalizeAddress($value);
|
||||
// The group's own address is covered by sender_allowed
|
||||
$valid = ($address !== null && $address !== $group->address && $this->lookup->aliasExists($address));
|
||||
}
|
||||
if (!$valid) {
|
||||
throw new UserError('group_send_as_invalid', [htmlspecialchars($value)]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf((string)$address))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$sendAs[] = (string)$address;
|
||||
}
|
||||
$sendAs = array_values(array_unique($sendAs));
|
||||
sort($sendAs);
|
||||
$group->sendAs = $sendAs;
|
||||
}
|
||||
|
||||
/**
|
||||
* `imap_grants` [{owner, rights, subscribe}] and `sogo_grants` [{owner, type, folder, access, subscribe}] replace
|
||||
* the grants; the editor needs access to every owner mailbox.
|
||||
*
|
||||
* @throws UserError group_grant_invalid, access_denied
|
||||
*/
|
||||
private function applyGrants(Group $group, array $input): void
|
||||
{
|
||||
if (array_key_exists('imap_grants', $input)) {
|
||||
$grants = [];
|
||||
foreach (self::grantList($input['imap_grants']) as $grant) {
|
||||
$owner = $this->grantOwner($grant);
|
||||
$rights = Input::text($grant['rights'] ?? 'read');
|
||||
if (!isset(SharingService::IMAP_RIGHTS[$rights])) {
|
||||
throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]);
|
||||
}
|
||||
$grants[$owner] = ['owner' => $owner, 'rights' => $rights, 'subscribe' => self::flag($grant['subscribe'] ?? 0)];
|
||||
}
|
||||
ksort($grants);
|
||||
$group->imapGrants = array_values($grants);
|
||||
}
|
||||
if (array_key_exists('sogo_grants', $input)) {
|
||||
$grants = [];
|
||||
foreach (self::grantList($input['sogo_grants']) as $grant) {
|
||||
$owner = $this->grantOwner($grant);
|
||||
$type = Input::text($grant['type'] ?? 'Calendar');
|
||||
$folder = Input::text($grant['folder'] ?? '') ?: 'personal';
|
||||
$access = Input::text($grant['access'] ?? 'read');
|
||||
if (!isset(GroupRepository::SOGO_ROLES[$type][$access]) || !preg_match('/^[A-Za-z0-9._-]{1,100}$/', $folder)) {
|
||||
throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]);
|
||||
}
|
||||
$grants[$owner . '|' . $type . '|' . $folder] = ['owner' => $owner, 'type' => $type, 'folder' => $folder, 'access' => $access,
|
||||
'subscribe' => self::flag($grant['subscribe'] ?? 0)];
|
||||
}
|
||||
ksort($grants);
|
||||
$group->sogoGrants = array_values($grants);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws UserError group_grant_invalid, access_denied */
|
||||
private function grantOwner(array $grant): string
|
||||
{
|
||||
$owner = strtolower((string)Input::text($grant['owner'] ?? ''));
|
||||
if ($owner === '' || empty($this->lookup->regularMailboxes([$owner]))) {
|
||||
throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]);
|
||||
}
|
||||
if (!$this->context->canAccessMailbox($owner)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant objects of a JSON body or of a form field (JSON string)
|
||||
*
|
||||
* @throws UserError group_grant_invalid
|
||||
*/
|
||||
private static function grantList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = ($value === '') ? [] : json_decode($value, true);
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
throw new UserError('group_grant_invalid', ['']);
|
||||
}
|
||||
foreach ($value as $grant) {
|
||||
if (!is_array($grant)) {
|
||||
throw new UserError('group_grant_invalid', ['']);
|
||||
}
|
||||
}
|
||||
return array_values($value);
|
||||
}
|
||||
|
||||
/** @throws UserError template_id_invalid, group_priority_invalid */
|
||||
private function applyAdminFields(Group $group, array $input): void
|
||||
{
|
||||
if (array_key_exists('template_id', $input)) {
|
||||
$id = $input['template_id'];
|
||||
if ($id === null || $id === '' || $id === '0' || $id === 0) {
|
||||
$group->templateId = null;
|
||||
$group->template = null;
|
||||
} else {
|
||||
$templateId = Input::intInRange($id, 1, PHP_INT_MAX);
|
||||
$name = ($templateId === null) ? null : $this->lookup->templateName($templateId);
|
||||
if ($name === null) {
|
||||
throw new UserError('template_id_invalid', [htmlspecialchars((string)Input::text($id))]);
|
||||
}
|
||||
$group->templateId = $templateId;
|
||||
$group->template = $name;
|
||||
}
|
||||
}
|
||||
if (isset($input['priority'])) {
|
||||
$priority = Input::intInRange($input['priority'], -1000000, 1000000);
|
||||
if ($priority === null) {
|
||||
throw new UserError('group_priority_invalid');
|
||||
}
|
||||
$group->priority = $priority;
|
||||
}
|
||||
if (array_key_exists('idp_ref', $input)) {
|
||||
$ref = Input::text($input['idp_ref']);
|
||||
if ($ref === null || mb_strlen($ref) > 255) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$group->idpRef = ($ref === '') ? null : $ref;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array[] */
|
||||
private function sizeWarnings(Group $group): array
|
||||
{
|
||||
if ($group->address !== null && count($group->members) > self::MAX_RECIPIENTS) {
|
||||
return [['group_too_many_members', htmlspecialchars($group->name), self::MAX_RECIPIENTS]];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||
private static function flag(mixed $value): int
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = end($value);
|
||||
}
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** @return string[] trimmed, lowercase, unique entries of an array or of a string separated by spaces, commas, semicolons or newlines */
|
||||
private static function cleanList(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : '');
|
||||
$clean = [];
|
||||
foreach ($values as $item) {
|
||||
if (is_scalar($item) && trim((string)$item) !== '') {
|
||||
$clean[] = strtolower(trim((string)$item));
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
/** Lowercase address with ASCII (IDN) domain, null if invalid */
|
||||
private static function normalizeAddress(string $value): ?string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
$at = strrpos($value, '@');
|
||||
if ($at === false || $at === 0) {
|
||||
return null;
|
||||
}
|
||||
$domain = idn_to_ascii(substr($value, $at + 1), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
$address = substr($value, 0, $at) . '@' . $domain;
|
||||
return ($domain !== false && filter_var($address, FILTER_VALIDATE_EMAIL)) ? $address : null;
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
return substr($address, strrpos($address, '@') + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Entry points for the IdP sync: system caller, no permission checks, never routed in json_api.
|
||||
* Only memberships with source 'idp' are managed here; manual memberships stay.
|
||||
*/
|
||||
class IdpSyncService
|
||||
{
|
||||
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private ChangeEffects $effects)
|
||||
{
|
||||
}
|
||||
|
||||
public function findByIdpRef(string $ref): ?Group
|
||||
{
|
||||
return $this->repo->findByIdpRef($ref);
|
||||
}
|
||||
|
||||
/** @return array[] every group with its members, see GroupRepository::syncState() */
|
||||
public function groups(): array
|
||||
{
|
||||
return $this->repo->syncState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the group of an IdP group, members follow with syncGroupMembers(). The group mail becomes the
|
||||
* distribution address when it is in the group's domain and free.
|
||||
*
|
||||
* @return array[] warnings (msg values)
|
||||
* @throws UserError
|
||||
*/
|
||||
public function createFromIdp(string $ref, string $name, string $domain, ?string $mail): array
|
||||
{
|
||||
$name = $this->validName($domain, $name, null);
|
||||
if (!$this->lookup->isDomain($domain)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars($domain)]);
|
||||
}
|
||||
$group = new Group();
|
||||
$group->domain = $domain;
|
||||
$group->name = $name;
|
||||
$group->idpRef = $ref;
|
||||
$warnings = [];
|
||||
if ($mail !== null && filter_var($mail, FILTER_VALIDATE_EMAIL) && str_ends_with($mail, '@' . $domain)) {
|
||||
if ($this->lookup->addressTaken($mail, null) || $this->lookup->aliasesLeft($domain) < 1) {
|
||||
$warnings[] = ['group_idp_address_skipped', htmlspecialchars($mail), htmlspecialchars($name)];
|
||||
} else {
|
||||
$group->address = $mail;
|
||||
}
|
||||
}
|
||||
$this->repo->insert($group);
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function renameFromIdp(int $groupId, string $name): void
|
||||
{
|
||||
$group = $this->repo->find($groupId);
|
||||
if ($group === null) {
|
||||
return;
|
||||
}
|
||||
$group->name = $this->validName($group->domain, $name, $groupId);
|
||||
$this->repo->update($group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the IdP members of a group. Usernames that are no regular mailbox are skipped; a manual member that
|
||||
* the IdP also lists stays manual.
|
||||
*
|
||||
* @return array{added: string[], removed: string[], skipped: string[], warnings: array[]}
|
||||
*/
|
||||
public function syncGroupMembers(int $groupId, array $usernames): array
|
||||
{
|
||||
$result = ['added' => [], 'removed' => [], 'skipped' => [], 'warnings' => []];
|
||||
$group = $this->repo->find($groupId);
|
||||
if ($group === null) {
|
||||
return $result;
|
||||
}
|
||||
$wanted = array_values(array_unique(array_map(fn(mixed $u): string => strtolower(trim((string)$u)), $usernames)));
|
||||
$regular = $this->lookup->regularMailboxes($wanted);
|
||||
$result['skipped'] = array_values(array_diff($wanted, $regular));
|
||||
$members = $group->members;
|
||||
foreach ($members as $username => $source) {
|
||||
if ($source === Group::SOURCE_IDP && !in_array((string)$username, $regular, true)) {
|
||||
unset($members[$username]);
|
||||
$result['removed'][] = (string)$username;
|
||||
}
|
||||
}
|
||||
foreach ($regular as $username) {
|
||||
if (!isset($members[$username])) {
|
||||
$members[$username] = Group::SOURCE_IDP;
|
||||
$result['added'][] = $username;
|
||||
}
|
||||
}
|
||||
if (empty($result['added']) && empty($result['removed'])) {
|
||||
return $result;
|
||||
}
|
||||
ksort($members);
|
||||
$snapshot = $this->effects->snapshot(array_merge($group->usernames(), array_keys($members)));
|
||||
$group->members = $members;
|
||||
$result['warnings'] = $this->effects->apply($this->repo->update($group), $snapshot);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function validName(string $domain, string $name, ?int $groupId): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '' || mb_strlen($name) > 100) {
|
||||
throw new UserError('group_name_invalid');
|
||||
}
|
||||
if ($this->repo->nameExists($domain, $name, $groupId)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\Dovecot\Service\DovecotService;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\Sogo\Service\SogoService;
|
||||
|
||||
/**
|
||||
* Mailbox access of group members (shared mailboxes, calendars, address books).
|
||||
*
|
||||
* IMAP: a grant is the Dovecot ACL group=mcg_<id> on every folder of the owner; members get the group through
|
||||
* the userdb field acl_groups, so membership changes need no ACL change. Dovecot is reached through dockerapi
|
||||
* after the commit; subfolders created later inherit the ACL, new top-level folders and failed grants are
|
||||
* handled by reconcile() (cron).
|
||||
* SOGo: sogo_acl rows per member, maintained by GroupRepository::rebuild(); missing personal folders of owners that
|
||||
* never logged in to SOGo (shared mailboxes) are created with sogo-tool init-user.
|
||||
* Grants with `subscribe` are subscribed for each member once by syncSubscriptions(); a member that removes the
|
||||
* subscription keeps it removed, members that lose the access are unsubscribed.
|
||||
*/
|
||||
class SharingService
|
||||
{
|
||||
/** Dovecot rights per grant level */
|
||||
public const IMAP_RIGHTS = [
|
||||
'read' => ['lookup', 'read', 'write-seen'],
|
||||
'write' => ['lookup', 'read', 'write-seen', 'write', 'write-deleted', 'insert', 'post', 'expunge', 'create'],
|
||||
'full' => ['lookup', 'read', 'write-seen', 'write', 'write-deleted', 'insert', 'post', 'expunge', 'create', 'delete'],
|
||||
];
|
||||
|
||||
public function __construct(private GroupRepository $repo, private DovecotService $dovecot, private SogoService $sogo,
|
||||
private ChangeEffects $effects)
|
||||
{
|
||||
}
|
||||
|
||||
/** Group name in the userdb field acl_groups (dovecot docker-entrypoint.sh) */
|
||||
public static function aclGroup(int $groupId): string
|
||||
{
|
||||
return 'mcg_' . $groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the IMAP grants Dovecot does not have yet (pending, failed, removing); $all also applies the applied
|
||||
* ones again. Of one group or all.
|
||||
*
|
||||
* @return array[] warnings (msg values)
|
||||
*/
|
||||
public function applyImap(?int $groupId, bool $all = false): array
|
||||
{
|
||||
$warnings = [];
|
||||
foreach ($this->repo->imapGrantsToApply($groupId, $all) as $grant) {
|
||||
$removing = ($grant['state'] === 'removing');
|
||||
$error = $this->dovecot->setAclAll((string)$grant['owner'], 'group=' . self::aclGroup((int)$grant['group_id']),
|
||||
$removing ? [] : self::IMAP_RIGHTS[$grant['rights']]);
|
||||
if ($error === null && $removing) {
|
||||
$this->repo->deleteImapGrant((int)$grant['id']);
|
||||
continue;
|
||||
}
|
||||
if ($error === null) {
|
||||
if ($grant['state'] !== 'applied' || $grant['last_error'] !== null) {
|
||||
$this->repo->setImapState((int)$grant['id'], 'applied', null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$this->repo->setImapState((int)$grant['id'], $removing ? 'removing' : 'failed', $error);
|
||||
$warnings[] = ['group_imap_grant_failed', htmlspecialchars((string)$grant['owner'])];
|
||||
}
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/** After a group write: creates missing SOGo folders of its grant owners and applies the pending grants to them */
|
||||
public function applySogo(int $groupId): array
|
||||
{
|
||||
$warnings = $this->initSogoFolders($groupId);
|
||||
// null: no grant waits for a folder, warnings: the folders could not be created
|
||||
if ($warnings === null || $warnings !== []) {
|
||||
return $warnings ?? [];
|
||||
}
|
||||
return $this->effects->apply($this->repo->rebuild($groupId), []);
|
||||
}
|
||||
|
||||
/** Before a group is deleted: its Dovecot ACLs go (best effort, the ACL of a deleted group grants nothing) */
|
||||
public function removeImap(Group $group): void
|
||||
{
|
||||
foreach ($group->imapGrants as $grant) {
|
||||
$this->dovecot->setAclAll((string)$grant['owner'], 'group=' . self::aclGroup((int)$group->id), []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron: applies all IMAP grants again (new top-level folders, failed grants), rebuilds the SOGo grants
|
||||
* (owners that opened SOGo meanwhile, rows of removed grants) and syncs the SOGo subscriptions.
|
||||
*
|
||||
* @return array[] warnings (msg values)
|
||||
*/
|
||||
public function reconcile(): array
|
||||
{
|
||||
$warnings = array_merge($this->applyImap(null, true), $this->initSogoFolders(null) ?? []);
|
||||
$users = [];
|
||||
foreach ($this->repo->groupIdsWithSogoGrants() as $id) {
|
||||
$users = array_merge($users, $this->repo->rebuild($id));
|
||||
}
|
||||
$this->repo->cleanupSogoAcl();
|
||||
$warnings = array_merge($warnings, $this->effects->apply(array_values(array_unique($users)), []));
|
||||
return array_merge($warnings, $this->syncSubscriptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes members that lost the access to a folder mailcow subscribed them to, then subscribes the members
|
||||
* of grants with `subscribe` that have no subscription by mailcow yet. Cheap without pending changes, the cron
|
||||
* runs it every minute.
|
||||
*
|
||||
* @return array[] warnings (msg values)
|
||||
*/
|
||||
public function syncSubscriptions(): array
|
||||
{
|
||||
$warnings = [];
|
||||
$sogoChanged = false;
|
||||
foreach (['imap', 'sogo'] as $kind) {
|
||||
foreach ($this->repo->staleSubscriptions($kind) as $row) {
|
||||
// A deleted member has no subscriptions left, a deleted owner no folders; another group may still grant it
|
||||
if ($row['member_exists'] && $row['owner_exists'] && !$row['covered']) {
|
||||
if ($this->subscribe($kind, (string)$row['owner'], (string)$row['folder'], [(string)$row['username']], true) !== null) {
|
||||
$warnings[] = ['group_subscription_failed', htmlspecialchars((string)$row['username'])];
|
||||
continue;
|
||||
}
|
||||
$sogoChanged = $sogoChanged || ($kind === 'sogo');
|
||||
}
|
||||
$this->repo->deleteSubscription((int)$row['id']);
|
||||
}
|
||||
foreach ($this->repo->missingSubscriptions($kind) as $grant) {
|
||||
if ($this->subscribe($kind, $grant['owner'], $grant['folder'], $grant['usernames']) !== null) {
|
||||
$warnings[] = ['group_subscription_failed', htmlspecialchars(implode(', ', $grant['usernames']))];
|
||||
continue;
|
||||
}
|
||||
$this->repo->addSubscriptions($kind, $grant['grant_id'], $grant['owner'], $grant['folder'], $grant['usernames']);
|
||||
$sogoChanged = $sogoChanged || ($kind === 'sogo');
|
||||
}
|
||||
}
|
||||
if ($sogoChanged) {
|
||||
$this->sogo->flushCache();
|
||||
}
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/** @return ?string null on success, else the error */
|
||||
private function subscribe(string $kind, string $owner, string $folder, array $usernames, bool $unsubscribe = false): ?string
|
||||
{
|
||||
return ($kind === 'imap') ? $this->dovecot->subscribeShared($owner, $usernames, $unsubscribe)
|
||||
: $this->sogo->subscribe($owner, $folder, $usernames, $unsubscribe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the personal SOGo folders of grant owners that never logged in to SOGo, so their pending grants can
|
||||
* be applied.
|
||||
*
|
||||
* @return ?array[] warnings, null if no grant was waiting for a folder
|
||||
*/
|
||||
private function initSogoFolders(?int $groupId): ?array
|
||||
{
|
||||
$owners = $this->repo->pendingSogoOwners($groupId);
|
||||
if (empty($owners)) {
|
||||
return null;
|
||||
}
|
||||
return ($this->sogo->initUsers($owners) === null) ? [] : [['group_sogo_init_failed', htmlspecialchars(implode(', ', $owners))]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailboxGroup\Service;
|
||||
|
||||
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Group templates: the effective template of a mailbox is the one of its active group with the highest
|
||||
* priority. It is applied only when it changes, so manual settings of a mailbox stay until then.
|
||||
* A mailbox that leaves all groups keeps its settings.
|
||||
*/
|
||||
class TemplateService
|
||||
{
|
||||
public function __construct(private LookupRepository $lookup, private LegacyMailbox $legacy)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, ?string> username => effective group template */
|
||||
public function effective(array $usernames): array
|
||||
{
|
||||
return $this->lookup->effectiveTemplates(array_values(array_unique($usernames)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the effective template to the mailboxes whose effective template differs from $before
|
||||
* (a snapshot of effective() taken before the change).
|
||||
*
|
||||
* @return array[] warnings (msg values)
|
||||
*/
|
||||
public function applyChanged(array $before): array
|
||||
{
|
||||
$changed = [];
|
||||
foreach ($this->effective(array_keys($before)) as $username => $template) {
|
||||
if ($template !== null && $template !== $before[$username]) {
|
||||
$changed[$username] = $template;
|
||||
}
|
||||
}
|
||||
return $this->apply($changed);
|
||||
}
|
||||
|
||||
/** Applies the effective templates again, e.g. after manual changes (admin action) */
|
||||
public function applyTo(array $usernames): array
|
||||
{
|
||||
return $this->apply(array_filter($this->effective($usernames), fn(?string $template): bool => $template !== null));
|
||||
}
|
||||
|
||||
/** A template was edited: apply it to the mailboxes it is the effective template of */
|
||||
public function onTemplateChanged(int $templateId): array
|
||||
{
|
||||
$name = $this->lookup->templateName($templateId);
|
||||
if ($name === null) {
|
||||
return [];
|
||||
}
|
||||
return $this->apply(array_filter($this->effective($this->lookup->membersWithTemplate($templateId)),
|
||||
fn(?string $template): bool => $template === $name));
|
||||
}
|
||||
|
||||
/** Template for IdP logins and syncs: the effective group template, else the legacy attribute mapping */
|
||||
public function templateForLogin(string $username, ?string $legacy): ?string
|
||||
{
|
||||
return $this->effective([$username])[$username] ?? $legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $templates username => template
|
||||
* @return array[] warnings (msg values)
|
||||
*/
|
||||
private function apply(array $templates): array
|
||||
{
|
||||
$warnings = [];
|
||||
$names = $this->lookup->mailboxNames(array_keys($templates));
|
||||
foreach ($templates as $username => $template) {
|
||||
$error = $this->legacy->applyTemplate((string)$username, (string)($names[$username] ?? ''), $template);
|
||||
if ($error !== null) {
|
||||
$warnings[] = ['group_template_failed', htmlspecialchars($template), htmlspecialchars((string)$username)];
|
||||
}
|
||||
}
|
||||
return $warnings;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user