mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-22 18:48:54 +00:00
[Web] Add Mailcow\ class structure and imapsync module classes
This commit is contained in:
26 files changed
+2821
No files matched your search
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
|
||||
/**
|
||||
* Use cases and rules for sync sources.
|
||||
*
|
||||
* Read: admin all; domain admin own + 'all' + sources targeting his domains/mailboxes + sources created by
|
||||
* his mailboxes; user own + 'all' + sources targeting him or his domain (see SourceRepository).
|
||||
* Edit: admin all; creator own; domain admin sources created by his mailboxes.
|
||||
* XOAUTH2 sources are created and edited by admins and domain admins only (users may use them).
|
||||
* Users and domain admins need the `syncjobs` ACL for everything.
|
||||
*/
|
||||
class SourceService
|
||||
{
|
||||
private const ENCRYPTIONS = ['TLS', 'SSL', 'PLAIN'];
|
||||
private const AUTH_TYPES = ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'];
|
||||
private const SCOPES = ['all', 'domain', 'user'];
|
||||
|
||||
public function __construct(private SourceRepository $repo, private TokenRepository $tokens, private OAuthService $oauth,
|
||||
private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied without the syncjobs ACL */
|
||||
public function requireAcl(): void
|
||||
{
|
||||
if (!$this->context->hasAcl('syncjobs')) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
/** Add/edit: a logged-in admin, domain admin or user with the syncjobs ACL */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!($this->context->isAdmin() || $this->context->isDomainAdmin() || $this->context->isUser())) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$this->requireAcl();
|
||||
}
|
||||
|
||||
/**
|
||||
* The source when the caller may see it (incl. secrets: never pass the object to the UI, use toArray()).
|
||||
* Users and domain admins without the syncjobs ACL see no sources.
|
||||
*/
|
||||
public function get(int $id): ?Source
|
||||
{
|
||||
return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null;
|
||||
}
|
||||
|
||||
/** @return int[] ids of the sources the caller may see, ordered by name */
|
||||
public function visibleIds(): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Source $source): bool
|
||||
{
|
||||
if ($this->context->accessAll || $this->context->isAdmin() || $source->createdBy === $this->context->username) {
|
||||
return true;
|
||||
}
|
||||
// A domain admin may manage sources created by mailboxes of his domains
|
||||
return $this->context->isDomainAdmin() && $source->createdBy !== '' && $this->context->canAccessMailbox($source->createdBy);
|
||||
}
|
||||
|
||||
/** API representation for the caller (can_edit, extra params only for editors, redirect URI) */
|
||||
public function toArray(Source $source): array
|
||||
{
|
||||
return $source->toArray($this->canEdit($source), $this->oauth->redirectUri());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a source from request attributes (C9 names; host1/port1/enc1 are accepted as aliases).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(array $input): Source
|
||||
{
|
||||
$this->requireEditor();
|
||||
$source = new Source();
|
||||
$source->createdBy = (string)$this->context->username;
|
||||
$this->applyBasics($source, $input);
|
||||
$this->applyOAuth($source, $input);
|
||||
// Scope and targets are validated before anything is written
|
||||
$this->applyScope($source, $input['scope'] ?? 'user', $input['domains'] ?? [], $input['users'] ?? []);
|
||||
if ($this->repo->nameExists($source->name, $source->createdBy)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->repo->insert($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a source; attributes missing in $input keep their value.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function update(int $id, array $input): Source
|
||||
{
|
||||
$this->requireEditor();
|
||||
$current = $this->editable($id);
|
||||
$source = clone $current;
|
||||
$this->applyBasics($source, $input);
|
||||
if ($source->name !== $current->name && $this->repo->nameExists($source->name, $source->createdBy, $id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->applyOAuth($source, $input);
|
||||
// Targets only change when the request carries `scope`; client_credentials sources are always private
|
||||
$writeTargets = isset($input['scope']) || $source->usesClientCredentials();
|
||||
if ($writeTargets) {
|
||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||
}
|
||||
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an unused source and returns it.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_in_use
|
||||
*/
|
||||
public function delete(int $id): Source
|
||||
{
|
||||
$this->requireAcl();
|
||||
$source = $this->editable($id);
|
||||
// FK ON DELETE RESTRICT prevents removal while sync jobs reference it
|
||||
if ($this->repo->isInUse($id)) {
|
||||
throw new UserError('imapsync_source_in_use', [$source->name]);
|
||||
}
|
||||
$this->repo->delete($id);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a new shared token for a client_credentials source now.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_refresh_unsupported, imapsync_source_token_refresh_failed
|
||||
*/
|
||||
public function refreshToken(int $id): Source
|
||||
{
|
||||
$this->requireAcl();
|
||||
$source = $this->editable($id);
|
||||
if (!$source->usesClientCredentials()) {
|
||||
throw new UserError('imapsync_source_refresh_unsupported');
|
||||
}
|
||||
$error = $this->oauth->refreshSourceToken($source);
|
||||
if ($error !== null) {
|
||||
throw new UserError('imapsync_source_token_refresh_failed', [$source->name . ' (' . $error . ')']);
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/** A visible, active authorization_code source the caller may sign in to (OAuth popup), else null */
|
||||
public function findConnectable(int $id): ?Source
|
||||
{
|
||||
$source = $this->get($id);
|
||||
return ($source !== null && $source->active === 1 && $source->usesAuthorizationCode()) ? $source : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account rename (mailbox, admin, domain admin): sources, user targets and token owners follow.
|
||||
* Leftover sources of an earlier account named $new are released first (see releaseSourcesOf()).
|
||||
* Runs on the caller's PDO; a PDOException is not caught, so a caller transaction can roll back.
|
||||
*/
|
||||
public function onAccountRenamed(string $old, string $new): void
|
||||
{
|
||||
if ($old === '' || $old === $new) {
|
||||
return;
|
||||
}
|
||||
$this->releaseSourcesOf($new);
|
||||
$this->repo->renameAccount($old, $new);
|
||||
$this->tokens->renameOwner($old, $new);
|
||||
}
|
||||
|
||||
/** Account deleted (after its jobs were deleted): sources released, tokens and user targets removed */
|
||||
public function onAccountDeleted(string $old): void
|
||||
{
|
||||
if ($old === '') {
|
||||
return;
|
||||
}
|
||||
$this->releaseSourcesOf($old);
|
||||
$this->tokens->deleteByOwner($old);
|
||||
$this->repo->deleteUserTarget($old);
|
||||
}
|
||||
|
||||
private function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
|
||||
}
|
||||
|
||||
/** @throws AccessDenied when the caller cannot see or edit the source */
|
||||
private function editable(int $id): Source
|
||||
{
|
||||
$source = $this->get($id);
|
||||
if ($source === null || !$this->canEdit($source)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources of an account are deleted when unused, otherwise handed over to the admins
|
||||
* (created_by ''), so a re-created account inherits nothing.
|
||||
*/
|
||||
private function releaseSourcesOf(string $username): void
|
||||
{
|
||||
foreach ($this->repo->listCreatedBy($username) as $source) {
|
||||
$id = (int)$source->id;
|
||||
if (!$this->repo->isInUse($id)) {
|
||||
// Explicit child deletes: the mailbox rename runs with FOREIGN_KEY_CHECKS = 0 (no cascade)
|
||||
$this->repo->deleteWithChildren($id);
|
||||
continue;
|
||||
}
|
||||
// Keep (created_by, name) unique when handing over
|
||||
$name = $this->repo->nameExists($source->name, '') ? mb_substr($source->name, 0, 80) . ' (#' . $id . ')' : $source->name;
|
||||
$this->repo->handOver($id, $name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name, description, connection and active from the request; missing attributes keep the value of $source.
|
||||
* On edit, stored host/port/enc that do not change are not validated again, so migrated legacy sources
|
||||
* stay editable (e.g. host 'imap_server.lan'). A host that only differs in case keeps the stored spelling.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function applyBasics(Source $source, array $input): void
|
||||
{
|
||||
$stored = ($source->id !== null);
|
||||
$name = isset($input['name']) ? Input::text($input['name']) : $source->name;
|
||||
$description = isset($input['description']) ? Input::text($input['description']) : $source->description;
|
||||
$host = $input['host'] ?? $input['host1'] ?? null;
|
||||
$host = ($host === null) ? $source->host : Input::text($host);
|
||||
if ($host !== null) {
|
||||
$host = (strtolower($host) === strtolower($source->host)) ? $source->host : strtolower($host);
|
||||
}
|
||||
$port = $input['port'] ?? $input['port1'] ?? $source->port;
|
||||
$port = is_scalar($port) ? (int)$port : 0;
|
||||
$enc = $input['enc'] ?? $input['enc1'] ?? $source->enc;
|
||||
$authType = $input['auth_type'] ?? $source->authType;
|
||||
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100) {
|
||||
throw new UserError('imapsync_source_name_invalid');
|
||||
}
|
||||
if ($description === null || mb_strlen($description) > 255) {
|
||||
throw new UserError('imapsync_source_description_invalid');
|
||||
}
|
||||
if (!($stored && $host === $source->host) && !$this->isValidHost($host)) {
|
||||
throw new UserError('imapsync_source_host_invalid');
|
||||
}
|
||||
if (!($stored && $port === $source->port) && ($port < 1 || $port > 65535)) {
|
||||
throw new UserError('imapsync_source_port_invalid');
|
||||
}
|
||||
if (!($stored && $enc === $source->enc) && !in_array($enc, self::ENCRYPTIONS, true)) {
|
||||
throw new UserError('imapsync_source_enc_invalid');
|
||||
}
|
||||
if (!in_array($authType, self::AUTH_TYPES, true)) {
|
||||
throw new UserError('imapsync_source_auth_invalid');
|
||||
}
|
||||
$source->name = $name;
|
||||
$source->description = $description;
|
||||
$source->host = $host;
|
||||
$source->port = $port;
|
||||
$source->enc = $enc;
|
||||
$source->authType = $authType;
|
||||
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
||||
}
|
||||
|
||||
private function isValidHost(?string $host): bool
|
||||
{
|
||||
return $host !== null && $host !== '' && strlen($host) <= 253
|
||||
&& (filter_var($host, FILTER_VALIDATE_IP) || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function applyOAuth(Source $source, array $input): void
|
||||
{
|
||||
// XOAUTH2 sources are managed by admins and domain admins only
|
||||
if ($source->isXOAuth2() && $this->context->isUser()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$this->oauth->applySettings($source, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates scope and targets for the caller and sets them; nothing is written here.
|
||||
* Users' sources and client_credentials sources (one app-wide token that reaches every mailbox) are always private.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_scope_empty
|
||||
*/
|
||||
private function applyScope(Source $source, mixed $scope, mixed $domains, mixed $users): void
|
||||
{
|
||||
if ($source->usesClientCredentials() || $this->context->isUser()) {
|
||||
[$source->scope, $source->domains, $source->users] = ['user', [], []];
|
||||
return;
|
||||
}
|
||||
// Only admins may create globally visible sources
|
||||
if (!is_string($scope) || !in_array($scope, self::SCOPES, true) || ($scope === 'all' && !$this->context->isAdmin())) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$domains = ($scope === 'domain') ? $this->cleanList($domains) : [];
|
||||
$users = ($scope === 'user') ? $this->cleanList($users) : [];
|
||||
if ($scope === 'domain' && empty($domains)) {
|
||||
throw new UserError('imapsync_source_scope_empty');
|
||||
}
|
||||
foreach ($domains as $domain) {
|
||||
if (!$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
foreach ($users as $user) {
|
||||
if (!$this->context->canAccessMailbox($user)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
[$source->scope, $source->domains, $source->users] = [$scope, $domains, $users];
|
||||
}
|
||||
|
||||
/** @return string[] trimmed, lowercase, unique, non-empty scalar values */
|
||||
private function cleanList(mixed $list): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach ((array)$list as $value) {
|
||||
if (is_scalar($value) && trim((string)$value) !== '') {
|
||||
$clean[] = strtolower(trim((string)$value));
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
|
||||
private static function connection(Source $source): array
|
||||
{
|
||||
return [(string)$source->authType, $source->host, $source->port, (string)$source->enc, $source->oauthFlow,
|
||||
(string)$source->tokenEndpoint, (string)$source->clientId];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user