[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,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);
}
}