mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-29 12:43:25 +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,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