[Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync

This commit is contained in:
FreddleSpl0it committed 2026-10-27 10:01:33 +01:00
1 parent 8bc8fbdd2c
commit 2c5718c9c9
82 files changed
+6488 -1027

No files matched your search

@@ -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;
}
}