From b2a152fe45f9a6bc11340374dd966a5b759182ff Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:03:41 +0200 Subject: [PATCH] [Web] Add Mailcow\ class structure and imapsync module classes --- data/web/inc/lib/composer.json | 5 + .../web/inc/lib/mailcow/Core/AccessDenied.php | 16 + data/web/inc/lib/mailcow/Core/ApiResponse.php | 70 ++++ data/web/inc/lib/mailcow/Core/Context.php | 76 ++++ data/web/inc/lib/mailcow/Core/Input.php | 35 ++ data/web/inc/lib/mailcow/Core/Repository.php | 65 ++++ data/web/inc/lib/mailcow/Core/UserError.php | 28 ++ .../Controller/ImapsyncApiController.php | 252 +++++++++++++ .../lib/mailcow/Imapsync/Http/OAuthClient.php | 172 +++++++++ .../mailcow/Imapsync/Http/OAuthException.php | 15 + .../lib/mailcow/Imapsync/ImapsyncModule.php | 73 ++++ .../mailcow/Imapsync/Model/CustomParams.php | 97 +++++ .../inc/lib/mailcow/Imapsync/Model/Job.php | 154 ++++++++ .../lib/mailcow/Imapsync/Model/OAuthToken.php | 38 ++ .../lib/mailcow/Imapsync/Model/Settings.php | 27 ++ .../inc/lib/mailcow/Imapsync/Model/Source.php | 143 ++++++++ .../Imapsync/Repository/JobRepository.php | 128 +++++++ .../Repository/SettingsRepository.php | 32 ++ .../Imapsync/Repository/SourceRepository.php | 215 +++++++++++ .../Imapsync/Repository/TokenRepository.php | 123 +++++++ .../mailcow/Imapsync/Service/JobService.php | 286 +++++++++++++++ .../mailcow/Imapsync/Service/OAuthService.php | 321 ++++++++++++++++ .../Imapsync/Service/SourceService.php | 347 ++++++++++++++++++ data/web/inc/lib/mailcow/README.md | 97 +++++ .../inc/lib/vendor/composer/autoload_psr4.php | 1 + .../lib/vendor/composer/autoload_static.php | 5 + 26 files changed, 2821 insertions(+) create mode 100644 data/web/inc/lib/mailcow/Core/AccessDenied.php create mode 100644 data/web/inc/lib/mailcow/Core/ApiResponse.php create mode 100644 data/web/inc/lib/mailcow/Core/Context.php create mode 100644 data/web/inc/lib/mailcow/Core/Input.php create mode 100644 data/web/inc/lib/mailcow/Core/Repository.php create mode 100644 data/web/inc/lib/mailcow/Core/UserError.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Controller/ImapsyncApiController.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Http/OAuthClient.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Http/OAuthException.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/ImapsyncModule.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Model/Job.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Model/OAuthToken.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Model/Settings.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Model/Source.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Repository/JobRepository.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Repository/SettingsRepository.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Repository/TokenRepository.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Service/JobService.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Service/OAuthService.php create mode 100644 data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php create mode 100644 data/web/inc/lib/mailcow/README.md diff --git a/data/web/inc/lib/composer.json b/data/web/inc/lib/composer.json index 3998065cb..5c3934fee 100644 --- a/data/web/inc/lib/composer.json +++ b/data/web/inc/lib/composer.json @@ -13,5 +13,10 @@ "stevenmaguire/oauth2-keycloak": "^4.0", "league/oauth2-client": "^2.7", "bacon/bacon-qr-code": "^2.0" + }, + "autoload": { + "psr-4": { + "Mailcow\\": "mailcow/" + } } } diff --git a/data/web/inc/lib/mailcow/Core/AccessDenied.php b/data/web/inc/lib/mailcow/Core/AccessDenied.php new file mode 100644 index 000000000..c17a69109 --- /dev/null +++ b/data/web/inc/lib/mailcow/Core/AccessDenied.php @@ -0,0 +1,16 @@ +success(...)` */ + public function success(string|array $msg, array $log): bool + { + $this->add('success', $msg, $log); + return true; + } + + /** Returns false, so callers can `return $response->danger(...)` */ + public function danger(string|array $msg, array $log): bool + { + $this->add('danger', $msg, $log); + return false; + } + + public function error(UserError $error, array $log): bool + { + return $this->danger($error->msg(), $log); + } + + /** Messages collected since the last flush() */ + public function messages(): array + { + return $this->messages; + } + + /** Appends the collected messages to $_SESSION['return'] and starts over */ + public function flush(): void + { + foreach ($this->messages as $message) { + $_SESSION['return'][] = $message; + } + $this->messages = []; + } + + private function add(string $type, string|array $msg, array $log): void + { + foreach ($log as $i => $part) { + if (!is_array($part)) { + continue; + } + foreach ($this->secretKeys as $key) { + if (isset($part[$key]) && $part[$key] !== '') { + $log[$i][$key] = '*'; + } + } + } + $this->messages[] = ['type' => $type, 'log' => $log, 'msg' => $msg]; + } +} diff --git a/data/web/inc/lib/mailcow/Core/Context.php b/data/web/inc/lib/mailcow/Core/Context.php new file mode 100644 index 000000000..58c462c59 --- /dev/null +++ b/data/web/inc/lib/mailcow/Core/Context.php @@ -0,0 +1,76 @@ +role === 'admin'; + } + + public function isDomainAdmin(): bool + { + return $this->role === 'domainadmin'; + } + + public function isUser(): bool + { + return $this->role === 'user'; + } + + public function hasAcl(string $name): bool + { + return isset($this->acl[$name]) && $this->acl[$name] == '1'; + } + + /** Bridge to the legacy hasMailboxObjectAccess() until functions.inc.php is converted */ + public function canAccessMailbox(string $mailbox): bool + { + return (bool)hasMailboxObjectAccess($this->username, $this->role, $mailbox); + } + + /** Bridge to the legacy hasDomainAccess() until functions.inc.php is converted */ + public function canAccessDomain(string $domain): bool + { + return (bool)hasDomainAccess($this->username, $this->role, $domain); + } +} diff --git a/data/web/inc/lib/mailcow/Core/Input.php b/data/web/inc/lib/mailcow/Core/Input.php new file mode 100644 index 000000000..fa6416cc4 --- /dev/null +++ b/data/web/inc/lib/mailcow/Core/Input.php @@ -0,0 +1,35 @@ + ['min_range' => $min, 'max_range' => $max]]); + return ($int === false) ? null : $int; + } +} diff --git a/data/web/inc/lib/mailcow/Core/Repository.php b/data/web/inc/lib/mailcow/Core/Repository.php new file mode 100644 index 000000000..d96d76175 --- /dev/null +++ b/data/web/inc/lib/mailcow/Core/Repository.php @@ -0,0 +1,65 @@ + 5]). + */ +class Repository +{ + public function __construct(protected PDO $pdo) + { + } + + /** Prepares and executes one statement */ + protected function query(string $sql, array $params = []): PDOStatement + { + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + /** Inserts one row (column => value) and returns the new id */ + protected function insertRow(string $table, array $values): int + { + $columns = array_keys($values); + $this->query("INSERT INTO `$table` (`" . implode('`, `', $columns) . "`) VALUES (:" . implode(', :', $columns) . ")", $values); + return (int)$this->pdo->lastInsertId(); + } + + /** Updates the columns (column => value) of the row with the given `id` */ + protected function updateRow(string $table, int $id, array $values): void + { + $set = implode(', ', array_map(fn(string $column): string => "`$column` = :$column", array_keys($values))); + $this->query("UPDATE `$table` SET $set WHERE `id` = :id", $values + ['id' => $id]); + } + + /** + * Runs $work in a transaction and returns its result. Inside a transaction of the caller it just runs, + * so the caller keeps control over commit and rollback. + */ + protected function transaction(callable $work): mixed + { + if ($this->pdo->inTransaction()) { + return $work(); + } + $this->pdo->beginTransaction(); + try { + $result = $work(); + $this->pdo->commit(); + return $result; + } catch (Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } +} diff --git a/data/web/inc/lib/mailcow/Core/UserError.php b/data/web/inc/lib/mailcow/Core/UserError.php new file mode 100644 index 000000000..e7a9b6636 --- /dev/null +++ b/data/web/inc/lib/mailcow/Core/UserError.php @@ -0,0 +1,28 @@ +args) ? $this->key : array_merge([$this->key], array_values($this->args)); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Controller/ImapsyncApiController.php b/data/web/inc/lib/mailcow/Imapsync/Controller/ImapsyncApiController.php new file mode 100644 index 000000000..721e0e0b3 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Controller/ImapsyncApiController.php @@ -0,0 +1,252 @@ +handle('add', 'job', $attr, + fn(array $log): bool => $this->response->success(['mailbox_modified', $this->jobs->create($attr)->user2], $log)); + } + + /** POST add/syncjob_source */ + public function addSource(array $attr): bool + { + return $this->handle('add', 'source', $attr, + fn(array $log): bool => $this->response->success(['imapsync_source_added', $this->sources->create($attr)->name], $log)); + } + + /** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */ + public function editJobs(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', 'job', $data, function (array $log) use ($data): bool { + $this->jobs->requireAcl(); + return $this->batch((array)($data['id'] ?? []), $log, + fn(int $id): array => ['mailbox_modified', $this->jobs->update($id, $data)->user2]); + }); + } + + /** POST edit/syncjob/run_next (admin): false when no job was moved */ + public function runNext(?array $items): bool + { + return $this->handle('edit', 'run_next', ['id' => $items], function (array $log) use ($items): bool { + $outcome = $this->jobs->runNext(array_map('intval', array_values(array_filter((array)$items, 'is_numeric')))); + foreach ($outcome['running'] as $id) { + $this->response->danger(['imapsync_job_running', $id], $log); + } + return ($outcome['moved'] > 0) ? $this->response->success(['imapsync_run_next_done', $outcome['moved']], $log) : false; + }); + } + + /** POST edit/syncjob_source: one message per id, non-numeric ids are skipped */ + public function editSources(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool { + $this->sources->requireEditor(); + return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log, + fn(int $id): array => ['imapsync_source_modified', $this->sources->update($id, $data)->name]); + }); + } + + /** POST edit/syncjob_source/refresh_token: the first id only */ + public function refreshSourceToken(?array $items): bool + { + return $this->handle('edit', 'refresh_token', ['id' => $items], function (array $log) use ($items): bool { + $source = $this->sources->refreshToken($this->requireId(is_array($items) ? reset($items) : null)); + return $this->response->success(['imapsync_source_token_refreshed', $source->name], $log); + }); + } + + /** POST edit/imapsync_settings (admin) */ + public function editSettings(array $attr): bool + { + return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool { + $this->jobs->saveSettings($attr); + return $this->response->success('max_parallel_saved', $log); + }); + } + + /** POST delete/syncjob: one message per id (with the id as requested) */ + public function deleteJobs(array $items): bool + { + return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool { + $this->jobs->requireAcl(); + return $this->batch($items, $log, function (int $id, mixed $requested): array { + $this->jobs->delete($id); + return ['deleted_syncjob', $requested]; + }); + }); + } + + /** POST delete/syncjob_source: one message per id, non-numeric ids are skipped */ + public function deleteSources(array $items): bool + { + return $this->handle('delete', 'source', ['id' => $items], function (array $log) use ($items): bool { + $this->sources->requireAcl(); + return $this->batch(array_filter($items, 'is_numeric'), $log, + fn(int $id): array => ['imapsync_source_deleted', $this->sources->delete($id)->name]); + }); + } + + /** GET syncjob_options */ + public function getOptions(): array + { + return $this->jobs->customParamOptions(); + } + + /** GET imapsync_settings (admin), also admin/system.php */ + public function getSettings(): array + { + try { + return $this->jobs->getSettings()->toArray(); + } catch (UserError) { + return []; + } + } + + /** GET syncjob_source/all */ + public function getSources(): array + { + return array_values(array_filter(array_map(fn(int $id): array => $this->getSource($id), $this->sources->visibleIds()))); + } + + /** GET syncjob_source/, also edit.php */ + public function getSource(mixed $id): array + { + $id = $this->toId($id); + $source = ($id === null) ? null : $this->sources->get($id); + return ($source === null) ? [] : $this->sources->toArray($source); + } + + /** + * GET syncjobs/all. Admins get every job (incl. orphaned ones). Others get the jobs of $mailboxes, + * the mailboxes the caller manages (json_api collects them with the legacy mailbox() functions + * until functions.mailbox.inc.php is converted), in queue order. + */ + public function getAllJobs(?string $extra, array $mailboxes): array + { + if ($this->jobs->canListAll()) { + return $this->jobRows($this->jobs->allIds(), $extra); + } + $ids = []; + foreach ($mailboxes as $mailbox) { + $ids = array_merge($ids, $this->jobs->idsForMailbox((string)$mailbox)); + } + $rows = $this->jobRows($ids, $extra); + // Queue order (C4): prio desc, never-run first, least recently run, id + usort($rows, fn(array $a, array $b): int => [$b['prio'], !empty($a['last_run']), (string)$a['last_run'], (int)$a['id']] + <=> [$a['prio'], !empty($b['last_run']), (string)$b['last_run'], (int)$b['id']]); + return $rows; + } + + /** GET syncjobs/; without a valid mailbox the caller's own jobs */ + public function getJobs(?string $mailbox, ?string $extra): array + { + return $this->jobRows($this->jobs->idsForMailbox($mailbox), $extra); + } + + /** One job (edit.php, inc/ajax/syncjob_logs.php); [] when missing or not accessible. `no_log` in $extra skips the log. */ + public function getJob(mixed $id, ?string $extra = null): array + { + $id = $this->toId($id); + $withLog = ($extra === null || !in_array('no_log', explode(',', $extra), true)); + $job = ($id === null) ? null : $this->jobs->get($id, $withLog); + return ($job === null) ? [] : $job->toArray($withLog); + } + + /** + * Account rename ($new) or delete ($new = null) from legacy account code that runs outside a transaction + * (admin, domain admin, mailbox delete): a database error becomes a `mysql_error` message. + * Inside a transaction (mailbox rename) call $module->sources->onAccountRenamed() directly instead, + * so the caller's catch can roll back. + */ + public function accountChanged(string $old, ?string $new = null): void + { + try { + if ($new === null) { + $this->sources->onAccountDeleted($old); + } else { + $this->sources->onAccountRenamed($old, $new); + } + } catch (PDOException $e) { + $this->response->danger(['mysql_error', $e->getMessage()], ['imapsync_account_changed', $old, $new]); + } + $this->response->flush(); + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, string $type, mixed $data, callable $work): bool + { + $log = ['syncjob', $action, $type, $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + /** One message per id: the success message returned by $work(id, requested id), or the UserError */ + private function batch(array $ids, array $log, callable $work): bool + { + foreach ($ids as $id) { + try { + $this->response->success($work($this->requireId($id), $id), $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + } + + /** @return array[] API rows of the jobs the caller may access */ + private function jobRows(array $ids, ?string $extra): array + { + return array_values(array_filter(array_map(fn(int $id): array => $this->getJob($id, $extra), $ids))); + } + + /** Integer id from request input ("12", 12); null for anything else, also "1.5" (matches no row) */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Http/OAuthClient.php b/data/web/inc/lib/mailcow/Imapsync/Http/OAuthClient.php new file mode 100644 index 000000000..9729d6644 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Http/OAuthClient.php @@ -0,0 +1,172 @@ +pin($url, $strict) !== null; + } + + /** + * Sends one request (POST form when $post is given, otherwise GET) and returns the decoded JSON object. + * + * @throws OAuthException with a sanitised message + */ + public function request(string $url, bool $strict, ?array $post = null, array $headers = []): array + { + $pin = $this->pin($url, $strict); + if ($pin === null) { + throw new OAuthException('endpoint not allowed'); + } + $options = [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROXY => '', + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + CURLOPT_MAXFILESIZE => 1048576, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers), + ]; + if (!empty($pin)) { + $options[CURLOPT_RESOLVE] = $pin; + } + if ($post !== null) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = http_build_query($post); + } + [$body, $code] = $this->send($options); + if ($body === false) { + throw new OAuthException('connection failed'); + } + $data = json_decode($body, true); + $ok = ($code >= 200 && $code < 300); + if ($ok && is_array($data)) { + return $data; + } + throw new OAuthException($this->errorText($code, $ok, $data)); + } + + /** + * IPv4 and IPv6 addresses of a host. + * + * @return string[] + */ + protected function resolve(string $host): array + { + $ips = @gethostbynamel($host) ?: []; + foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $record) { + $ips[] = $record['ipv6'] ?? ''; + } + return $ips; + } + + /** + * Runs curl with the given options. + * + * @return array{0: string|false, 1: int} response body (false on connection errors) and HTTP status + */ + protected function send(array $options): array + { + $ch = curl_init(); + curl_setopt_array($ch, $options); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [is_string($body) ? $body : false, $code]; + } + + /** + * Applies the endpoint policy. Returns null when the URL is not allowed, otherwise the CURLOPT_RESOLVE + * pin (empty when not strict). + */ + private function pin(string $url, bool $strict): ?array + { + if (strlen($url) > self::MAX_URL_LENGTH || preg_match('/[\x00-\x20\x7f]/', $url)) { + return null; + } + $parts = parse_url($url); + if (!$parts || strtolower($parts['scheme'] ?? '') !== 'https' || isset($parts['user']) || isset($parts['pass'])) { + return null; + } + $host = strtolower($parts['host'] ?? ''); + if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !filter_var($url, FILTER_VALIDATE_URL)) { + return null; + } + if (!$strict) { + return []; + } + $ips = array_values(array_unique($this->resolve($host))); + if (empty($ips)) { + return null; + } + $pinned = []; + foreach ($ips as $ip) { + if (!$this->isPublic($ip)) { + return null; + } + $pinned[] = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $ip . ']' : $ip; + } + return [$host . ':' . (int)($parts['port'] ?? 443) . ':' . implode(',', $pinned)]; + } + + /** + * Public unicast address only: no private, loopback, link-local or reserved ranges, no CGNAT (100.64/10), + * benchmarking (198.18/15) or multicast/reserved (224/3); IPv6 only global unicast 2000::/3 without + * 6to4 (2002::/16), Teredo (2001::/32) and documentation (2001:db8::/32). + */ + private function isPublic(string $ip): bool + { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $long = ip2long($ip); + return ($long & 0xFFC00000) != 0x64400000 + && ($long & 0xFFFE0000) != 0xC6120000 + && ($long & 0xE0000000) != 0xE0000000; + } + $hex = bin2hex((string)inet_pton($ip)); + return ($hex[0] === '2' || $hex[0] === '3') + && substr($hex, 0, 4) !== '2002' + && !in_array(substr($hex, 0, 8), ['20010000', '20010db8'], true); + } + + /** + * Error text that is safe to store: "HTTP " plus the provider's error / error_description. + */ + private function errorText(int $code, bool $ok, mixed $data): string + { + $text = 'HTTP ' . $code . ($ok ? ': malformed response' : ''); + $detail = []; + foreach (['error', 'error_description'] as $key) { + if (is_array($data) && isset($data[$key]) && is_scalar($data[$key]) && (string)$data[$key] !== '') { + $detail[] = (string)$data[$key]; + } + } + if (!empty($detail)) { + $text .= ': ' . implode(' - ', $detail); + } + return mb_substr(trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', strip_tags($text))), 0, self::MAX_ERROR_LENGTH); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Http/OAuthException.php b/data/web/inc/lib/mailcow/Imapsync/Http/OAuthException.php new file mode 100644 index 000000000..a8b5cac81 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Http/OAuthException.php @@ -0,0 +1,15 @@ +" plus the provider's + * error fields, or a fixed text; never a raw response body or curl details. + */ +class OAuthException extends RuntimeException +{ +} diff --git a/data/web/inc/lib/mailcow/Imapsync/ImapsyncModule.php b/data/web/inc/lib/mailcow/Imapsync/ImapsyncModule.php new file mode 100644 index 000000000..caa07b5cf --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/ImapsyncModule.php @@ -0,0 +1,73 @@ + CustomParams::allowlist($IMAPSYNC_OPTIONS, $IMAPSYNC_OPTIONS_DEFAULT), + * 'hostname' => (string)getenv('MAILCOW_HOSTNAME'), + * ]); + * process_add_return($imapsync->api->addJob($attr)); + * + * Procedural legacy code (json_api.php, pages, functions.*.inc.php) uses fromGlobals() instead. + */ +class ImapsyncModule +{ + /** Request keys whose values never reach a log */ + private const SECRET_KEYS = ['password1', 'password2', 'oauth_client_secret', 'oauth_extra_params']; + + public function __construct( + public readonly SourceService $sources, + public readonly JobService $jobs, + public readonly OAuthService $oauth, + public readonly ImapsyncApiController $api + ) { + } + + /** + * @param array $config 'allowlist' => effective custom param allowlist (CustomParams::allowlist()), + * 'hostname' => MAILCOW_HOSTNAME (OAuth redirect URI); both optional + */ + public static function create(PDO $pdo, Context $context, array $config = []): self + { + $sourceRepository = new SourceRepository($pdo); + $tokenRepository = new TokenRepository($pdo); + $oauth = new OAuthService($sourceRepository, $tokenRepository, new OAuthClient(), (string)($config['hostname'] ?? '')); + $sources = new SourceService($sourceRepository, $tokenRepository, $oauth, $context); + $jobs = new JobService(new JobRepository($pdo), $sources, $tokenRepository, new SettingsRepository($pdo), + $context, (array)($config['allowlist'] ?? [])); + return new self($sources, $jobs, $oauth, new ImapsyncApiController($sources, $jobs, new ApiResponse(self::SECRET_KEYS))); + } + + /** + * Legacy entry point for procedural callers: the one place that reads the globals ($pdo, $_SESSION, + * $IMAPSYNC_OPTIONS / $IMAPSYNC_OPTIONS_DEFAULT from vars(.local).inc.php, MAILCOW_HOSTNAME) + * and passes them to create(). $context overrides the session caller (e.g. Context::system()). + */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [ + 'allowlist' => CustomParams::allowlist((array)($GLOBALS['IMAPSYNC_OPTIONS'] ?? []), + (array)($GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? [])), + 'hostname' => (string)getenv('MAILCOW_HOSTNAME'), + ]); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php new file mode 100644 index 000000000..4a900e855 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Model/CustomParams.php @@ -0,0 +1,97 @@ + self::MAX_VALUE_LENGTH || count($pairs) >= self::MAX_PAIRS) { + throw new UserError('imapsync_custom_params_format'); + } + $pairs[] = ['o' => $option, 'v' => $value]; + } + return json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old + * 'whitelist' key and malformed entries; the defaults when nothing valid is left. + */ + public static function allowlist(array $configured, array $defaults): array + { + $allowlist = []; + foreach ($configured as $option => $rule) { + $option = strtolower(ltrim(trim((string)$option), '-')); + if ($option !== 'whitelist' && preg_match('/^[a-z][a-z0-9]*$/', $option) + && (is_bool($rule) || (is_string($rule) && $rule !== ''))) { + $allowlist[$option] = $rule; + } + } + if (!self::$warnedOldFormat && isset($configured['whitelist'])) { + self::$warnedOldFormat = true; + error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)"); + } + return empty($allowlist) ? $defaults : $allowlist; + } + + /** The value to store for one option; throws when the allowlist rule is violated */ + private static function checkValue(string $option, string $value, array $allowlist): string + { + $rule = $allowlist[$option] ?? null; + if ($option === '' || !(is_bool($rule) || is_string($rule))) { + throw new UserError('imapsync_custom_param_invalid', [htmlspecialchars($option)]); + } + $empty = (trim($value) === ''); + $valid = match (true) { + $rule === false => $empty, + $rule === true => !$empty, + default => !$empty && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $value) === 1, + }; + if (!$valid) { + throw new UserError('imapsync_custom_param_value', [$option]); + } + return ($rule === false) ? '' : $value; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/Job.php b/data/web/inc/lib/mailcow/Imapsync/Model/Job.php new file mode 100644 index 000000000..438049874 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Model/Job.php @@ -0,0 +1,154 @@ +` (columns of the source) + * and `user_token_*` columns. + */ + public static function fromRow(array $row): self + { + $job = new self(); + $job->id = isset($row['id']) ? (int)$row['id'] : null; + foreach (['authmd51', 'maxage', 'delete2duplicates', 'delete1', 'delete2', 'automap', 'skipcrossduplicates', 'timeout1', + 'timeout2', 'subscribeall', 'dry', 'active', 'prio'] as $column) { + $job->$column = (int)($row[$column] ?? 0); + } + foreach (['user2', 'domain2', 'subfolder2', 'user1', 'password1', 'exclude'] as $column) { + $job->$column = (string)($row[$column] ?? ''); + } + $job->minsInterval = (int)($row['mins_interval'] ?? 0); + $job->maxBytesPerSecond = (string)($row['maxbytespersecond'] ?? '0'); + $job->isRunning = (int)($row['is_running'] ?? 0); + $job->success = isset($row['success']) ? (int)$row['success'] : null; + // Nullable text columns (column => property) + foreach (['regextrans2' => 'regextrans2', 'custom_params' => 'customParams', 'custom_params_legacy' => 'customParamsLegacy', + 'returned_text' => 'returnedText', 'last_run' => 'lastRun', 'exit_status' => 'exitStatus', 'created' => 'created', + 'modified' => 'modified'] as $column => $property) { + $job->$property = isset($row[$column]) ? (string)$row[$column] : null; + } + $job->sourceId = (int)($row['source_id'] ?? 0); + if (isset($row['source_name'])) { + // source_id is the job's foreign key, i.e. the id of the source + $source = []; + foreach ($row as $key => $value) { + if (str_starts_with($key, 'source_')) { + $source[substr($key, 7)] = $value; + } + } + $job->source = Source::fromRow($source); + } + if (!empty($row['user_token_exists'])) { + $job->token = OAuthToken::fromRow(['source_id' => $job->sourceId, 'owner' => $job->user2, 'username' => $job->user1, + 'token_expires' => $row['user_token_expires'] ?? null, 'last_refresh_error' => $row['user_token_error'] ?? null]); + } + return $job; + } + + /** + * API representation (GET syncjobs, edit page, log view). Never contains password1. + * Without $withLog the `log` field is empty (UI tables request `no_log`). + */ + public function toArray(bool $withLog = true): array + { + $source = $this->source; + return [ + 'id' => $this->id, + 'user2' => $this->user2, + 'regextrans2' => $this->regextrans2, + 'authmd51' => $this->authmd51, + 'domain2' => $this->domain2, + 'subfolder2' => $this->subfolder2, + 'user1' => $this->user1, + 'exclude' => $this->exclude, + 'maxage' => $this->maxage, + 'mins_interval' => $this->minsInterval, + 'maxbytespersecond' => $this->maxBytesPerSecond, + 'delete2duplicates' => $this->delete2duplicates, + 'delete1' => $this->delete1, + 'delete2' => $this->delete2, + 'automap' => $this->automap, + 'skipcrossduplicates' => $this->skipcrossduplicates, + 'custom_params' => $this->customParams, + 'timeout1' => $this->timeout1, + 'timeout2' => $this->timeout2, + 'subscribeall' => $this->subscribeall, + 'dry' => $this->dry, + 'is_running' => $this->isRunning, + 'last_run' => $this->lastRun, + 'success' => $this->success, + 'exit_status' => $this->exitStatus, + 'created' => $this->created, + 'modified' => $this->modified, + 'active' => $this->active, + 'source_id' => $this->sourceId, + 'custom_params_legacy' => $this->customParamsLegacy, + 'prio' => $this->prio, + 'source_name' => $source?->name, + 'source_description' => $source?->description, + 'source_host' => $source?->host, + 'source_port' => $source?->port, + 'source_enc' => $source?->enc, + 'source_auth_type' => $source?->authType, + 'source_created_by' => $source?->createdBy, + 'source_scope' => $source?->scope, + 'source_oauth_flow' => $source?->oauthFlow, + 'source_active' => $source?->active, + 'source_oauth_token_expires' => $source?->tokenExpires, + 'source_oauth_last_refresh_error' => $source?->lastRefreshError, + 'user_token_expires' => $this->token?->tokenExpires, + 'user_token_error' => $this->token?->lastRefreshError, + 'user_token_exists' => $this->token === null ? 0 : 1, + 'log' => ($withLog && !empty($this->returnedText)) ? $this->returnedText : '', + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/OAuthToken.php b/data/web/inc/lib/mailcow/Imapsync/Model/OAuthToken.php new file mode 100644 index 000000000..c0d28f80d --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Model/OAuthToken.php @@ -0,0 +1,38 @@ +sourceId = (int)($row['source_id'] ?? 0); + $token->owner = (string)($row['owner'] ?? ''); + $token->username = (string)($row['username'] ?? ''); + $token->accessToken = isset($row['access_token']) ? (string)$row['access_token'] : null; + $token->refreshToken = isset($row['refresh_token']) ? (string)$row['refresh_token'] : null; + $token->tokenExpires = isset($row['token_expires']) ? (int)$row['token_expires'] : null; + $token->lastRefreshError = isset($row['last_refresh_error']) ? (string)$row['last_refresh_error'] : null; + $token->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null; + return $token; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/Settings.php b/data/web/inc/lib/mailcow/Imapsync/Model/Settings.php new file mode 100644 index 000000000..24eb4d268 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Model/Settings.php @@ -0,0 +1,27 @@ + $this->maxParallel, + 'max_bytes_per_second' => $this->maxBytesPerSecond, + 'max_kb_per_second' => intdiv($this->maxBytesPerSecond, 1024), + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Model/Source.php b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php new file mode 100644 index 000000000..a76e73c5f --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Model/Source.php @@ -0,0 +1,143 @@ +id = isset($row['id']) ? (int)$row['id'] : null; + $source->name = (string)($row['name'] ?? ''); + $source->description = (string)($row['description'] ?? ''); + $source->createdBy = (string)($row['created_by'] ?? ''); + $source->scope = (string)($row['scope'] ?? 'user'); + $source->host = (string)($row['host1'] ?? ''); + $source->port = (int)($row['port1'] ?? 0); + $source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS); + $source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null; + $source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null; + // Nullable text columns (column => property) + foreach (['enc1' => 'enc', 'auth_type' => 'authType', 'oauth_token_endpoint' => 'tokenEndpoint', + 'oauth_authorize_endpoint' => 'authorizeEndpoint', 'oauth_userinfo_endpoint' => 'userinfoEndpoint', + 'oauth_client_id' => 'clientId', 'oauth_client_secret' => 'clientSecret', 'oauth_scope' => 'oauthScope', + 'oauth_extra_params' => 'extraParams', 'oauth_access_token' => 'accessToken', 'oauth_last_refresh_error' => 'lastRefreshError', + 'oauth_last_refresh_attempt' => 'lastRefreshAttempt', 'created' => 'created', 'modified' => 'modified'] as $column => $property) { + $source->$property = isset($row[$column]) ? (string)$row[$column] : null; + } + $source->active = (int)($row['active'] ?? 1); + return $source; + } + + public function isXOAuth2(): bool + { + return $this->authType === self::AUTH_XOAUTH2; + } + + /** XOAUTH2 with one app-wide token that reaches every mailbox of the tenant */ + public function usesClientCredentials(): bool + { + return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_CLIENT_CREDENTIALS; + } + + /** XOAUTH2 with a per-user token from the browser sign-in */ + public function usesAuthorizationCode(): bool + { + return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_AUTHORIZATION_CODE; + } + + /** + * API representation (GET syncjob_source). Secrets and tokens are replaced by has_* flags; + * extra params are only shown to callers who may edit the source. + */ + public function toArray(bool $canEdit, string $redirectUri): array + { + $data = [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'created_by' => $this->createdBy, + 'scope' => $this->scope, + 'host1' => $this->host, + 'port1' => $this->port, + 'enc1' => $this->enc, + 'auth_type' => $this->authType, + 'oauth_flow' => $this->oauthFlow, + 'oauth_token_endpoint' => $this->tokenEndpoint, + 'oauth_authorize_endpoint' => $this->authorizeEndpoint, + 'oauth_userinfo_endpoint' => $this->userinfoEndpoint, + 'oauth_client_id' => $this->clientId, + 'oauth_scope' => $this->oauthScope, + 'oauth_extra_params' => $canEdit ? $this->extraParams : null, + 'oauth_token_expires' => $this->tokenExpires, + 'oauth_last_refresh_error' => $this->lastRefreshError, + 'oauth_last_refresh_attempt' => $this->lastRefreshAttempt, + 'created' => $this->created, + 'modified' => $this->modified, + 'active' => $this->active, + 'domains' => $this->domains, + 'users' => $this->users, + 'can_edit' => $canEdit, + 'host' => $this->host, + 'port' => $this->port, + 'enc' => $this->enc, + 'has_client_secret' => !empty($this->clientSecret), + 'has_access_token' => !empty($this->accessToken), + ]; + if ($this->usesAuthorizationCode()) { + $data['oauth_redirect_uri'] = $redirectUri; + } + return $data; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/JobRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/JobRepository.php new file mode 100644 index 000000000..04654906a --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/JobRepository.php @@ -0,0 +1,128 @@ +query("SELECT i.`" . implode("`, i.`", $columns) . "`, + s.`name` AS source_name, s.`description` AS source_description, s.`host1` AS source_host1, + s.`port1` AS source_port1, s.`enc1` AS source_enc1, s.`auth_type` AS source_auth_type, + s.`created_by` AS source_created_by, s.`scope` AS source_scope, s.`oauth_flow` AS source_oauth_flow, + s.`active` AS source_active, s.`oauth_token_expires` AS source_oauth_token_expires, + s.`oauth_last_refresh_error` AS source_oauth_last_refresh_error, + t.`token_expires` AS user_token_expires, t.`last_refresh_error` AS user_token_error, + CASE WHEN t.`username` IS NULL THEN 0 ELSE 1 END AS user_token_exists + FROM `imapsync` i + LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id` + LEFT JOIN `imapsync_source_oauth_token` t + ON t.`source_id` = i.`source_id` AND t.`owner` = i.`user2` AND t.`username` = i.`user1` + WHERE i.`id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC); + return $row ? Job::fromRow($row) : null; + } + + /** @return int[] job ids of a local mailbox in queue order */ + public function idsForMailbox(string $user2): array + { + return array_map('intval', $this->query("SELECT `id` FROM `imapsync` WHERE `user2` = :user2 " . self::QUEUE_ORDER, + ['user2' => $user2])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** @return int[] all job ids (incl. orphaned ones) in queue order */ + public function allIds(): array + { + return array_map('intval', $this->query("SELECT `id` FROM `imapsync` " . self::QUEUE_ORDER)->fetchAll(PDO::FETCH_COLUMN)); + } + + /** True when another job (not $exceptId) already syncs (source, user1) into user2 */ + public function tupleExists(int $sourceId, string $user2, string $user1, ?int $exceptId = null): bool + { + return (bool)$this->query("SELECT 1 FROM `imapsync` + WHERE `user2` = :user2 AND `user1` = :user1 AND `source_id` = :source_id AND `id` != :id", + ['user2' => $user2, 'user1' => $user1, 'source_id' => $sourceId, 'id' => $exceptId ?? 0])->fetchColumn(); + } + + public function insert(Job $job): int + { + return $job->id = $this->insertRow('imapsync', $this->values($job) + ['user2' => $job->user2]); + } + + public function update(Job $job): void + { + $this->updateRow('imapsync', (int)$job->id, + $this->values($job) + ['last_run' => $job->lastRun, 'success' => $job->success, 'prio' => $job->prio]); + } + + public function delete(int $id): void + { + $this->query("DELETE FROM `imapsync` WHERE `id` = :id", ['id' => $id]); + } + + /** + * Moves idle jobs to the front of the queue: each gets a prio above the current global max + * (lowest current prio first) and last_run/success are cleared. The rows are locked first, so the + * runner cannot claim a job in between (its claim UPDATE waits for this transaction). + * + * @param int[] $ids + * @return array{running: int[], moved: int} ids that are running (left alone) and the number of moved jobs + */ + public function moveToFront(array $ids): array + { + return $this->transaction(function () use ($ids): array { + $rows = $this->query("SELECT `id`, `is_running` FROM `imapsync` WHERE `id` IN (" . self::placeholders($ids) . ") FOR UPDATE", + $ids)->fetchAll(PDO::FETCH_KEY_PAIR); + $running = array_keys(array_filter($rows, fn($isRunning): bool => (int)$isRunning === 1)); + $idle = array_values(array_diff(array_keys($rows), $running)); + $moved = 0; + if (!empty($idle)) { + $this->pdo->exec("SET @m := (SELECT COALESCE(MAX(`prio`), 0) FROM `imapsync`)"); + $this->pdo->exec("SET @r := 0"); + $moved = $this->query("UPDATE `imapsync` SET `prio` = @m + (@r := @r + 1), `last_run` = NULL, `success` = NULL + WHERE `id` IN (" . self::placeholders($idle) . ") AND `is_running` = 0 ORDER BY `prio` ASC, `id` ASC", $idle)->rowCount(); + } + return ['running' => $running, 'moved' => $moved]; + }); + } + + /** Values of the columns a user can edit (shared by insert and update) */ + private function values(Job $job): array + { + $values = ['mins_interval' => $job->minsInterval, 'maxbytespersecond' => $job->maxBytesPerSecond, + 'custom_params' => $job->customParams, 'source_id' => $job->sourceId]; + // Columns with a property of the same name + foreach (['user1', 'password1', 'subfolder2', 'exclude', 'maxage', 'timeout1', 'timeout2', 'active', 'delete1', 'delete2', + 'delete2duplicates', 'automap', 'skipcrossduplicates', 'subscribeall', 'dry'] as $column) { + $values[$column] = $job->$column; + } + return $values; + } + + private static function placeholders(array $values): string + { + return implode(',', array_fill(0, count($values), '?')); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/SettingsRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/SettingsRepository.php new file mode 100644 index 000000000..6f96e512e --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/SettingsRepository.php @@ -0,0 +1,32 @@ + value). + */ +class SettingsRepository extends Repository +{ + /** Stored settings; keys that are not stored yet keep the defaults of the model */ + public function load(): Settings + { + $rows = $this->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR); + $settings = new Settings(); + $settings->maxParallel = (int)($rows['max_parallel'] ?? $settings->maxParallel); + $settings->maxBytesPerSecond = (int)($rows['max_bytes_per_second'] ?? $settings->maxBytesPerSecond); + return $settings; + } + + public function save(Settings $settings): void + { + $sql = "INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)"; + $this->query($sql, ['name' => 'max_parallel', 'value' => (string)$settings->maxParallel]); + $this->query($sql, ['name' => 'max_bytes_per_second', 'value' => (string)$settings->maxBytesPerSecond]); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php new file mode 100644 index 000000000..b35397013 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/SourceRepository.php @@ -0,0 +1,215 @@ +fetch('1=1', ['id' => $id]); + } + + /** Like find(), but only when the source is visible to the caller */ + public function findVisible(int $id, Context $context): ?Source + { + [$visible, $params] = $this->visibility($context); + return $this->fetch($visible, $params + ['id' => $id]); + } + + /** @return int[] ids of all sources visible to the caller, ordered by name */ + public function visibleIds(Context $context): array + { + [$visible, $params] = $this->visibility($context); + return array_map('intval', $this->query("SELECT s.`id` FROM `imapsync_source` s WHERE $visible ORDER BY s.`name`, s.`id`", + $params)->fetchAll(PDO::FETCH_COLUMN)); + } + + public function nameExists(string $name, string $createdBy, ?int $exceptId = null): bool + { + return (bool)$this->query("SELECT 1 FROM `imapsync_source` WHERE `name` = :name AND `created_by` = :created_by AND `id` != :id", + ['name' => $name, 'created_by' => $createdBy, 'id' => $exceptId ?? 0])->fetchColumn(); + } + + /** True while a sync job references the source */ + public function isInUse(int $id): bool + { + return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn(); + } + + public function isSuperAdmin(string $username): bool + { + return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'", + ['username' => $username])->fetchColumn(); + } + + /** Inserts the source with its targets and returns the new id */ + public function insert(Source $source): int + { + return $this->transaction(function () use ($source): int { + $source->id = $this->insertRow('imapsync_source', + $this->values($source) + ['created_by' => $source->createdBy, 'scope' => $source->scope]); + $this->writeTargets($source); + return $source->id; + }); + } + + /** + * Updates the source. $writeTargets also rewrites scope + target lists, $resetTokens drops the shared + * token and all per-user tokens of the source. All in one transaction. + */ + public function update(Source $source, bool $writeTargets, bool $resetTokens): void + { + $this->transaction(function () use ($source, $writeTargets, $resetTokens): void { + $id = ['id' => $source->id]; + $this->updateRow('imapsync_source', (int)$source->id, $this->values($source)); + if ($writeTargets) { + $this->query("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id", $id); + $this->query("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id", $id); + $this->writeTargets($source); + } + if ($resetTokens) { + $this->query("UPDATE `imapsync_source` SET `oauth_access_token` = NULL, `oauth_token_expires` = NULL, + `oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NULL WHERE `id` = :id", $id); + $this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `source_id` = :id", $id); + } + }); + } + + /** Deletes the source; targets and per-user tokens go via FK ON DELETE CASCADE */ + public function delete(int $id): void + { + $this->query("DELETE FROM `imapsync_source` WHERE `id` = :id", ['id' => $id]); + } + + /** Deletes the source and its child rows explicitly, for callers running with FOREIGN_KEY_CHECKS = 0 (mailbox rename) */ + public function deleteWithChildren(int $id): void + { + foreach (['imapsync_source_oauth_token', 'imapsync_source_user', 'imapsync_source_domain'] as $table) { + $this->query("DELETE FROM `$table` WHERE `source_id` = :id", ['id' => $id]); + } + $this->delete($id); + } + + /** @return Source[] sources created by an account (id and name only) */ + public function listCreatedBy(string $username): array + { + return array_map([Source::class, 'fromRow'], $this->query("SELECT `id`, `name` FROM `imapsync_source` WHERE `created_by` = :username", + ['username' => $username])->fetchAll(PDO::FETCH_ASSOC)); + } + + /** Hands a source over to the admins (created_by '') under the given name */ + public function handOver(int $id, string $name): void + { + $this->query("UPDATE `imapsync_source` SET `created_by` = '', `name` = :name WHERE `id` = :id", ['name' => $name, 'id' => $id]); + } + + /** Follows an account rename: creator and user targets */ + public function renameAccount(string $old, string $new): void + { + $this->query("UPDATE `imapsync_source` SET `created_by` = :new WHERE `created_by` = :old", ['new' => $new, 'old' => $old]); + $this->query("UPDATE `imapsync_source_user` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]); + } + + /** Removes a deleted account from all user target lists */ + public function deleteUserTarget(string $username): void + { + $this->query("DELETE FROM `imapsync_source_user` WHERE `username` = :username", ['username' => $username]); + } + + private function fetch(string $where, array $params): ?Source + { + $row = $this->query("SELECT s.* FROM `imapsync_source` s WHERE s.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $source = Source::fromRow($row); + $id = ['id' => $source->id]; + $source->domains = $this->query("SELECT `domain` FROM `imapsync_source_domain` WHERE `source_id` = :id ORDER BY `domain`", + $id)->fetchAll(PDO::FETCH_COLUMN); + $source->users = $this->query("SELECT `username` FROM `imapsync_source_user` WHERE `source_id` = :id ORDER BY `username`", + $id)->fetchAll(PDO::FETCH_COLUMN); + return $source; + } + + /** + * WHERE fragment over `imapsync_source s` that limits rows to what the caller may see, and its parameters. + * admin everything + * domainadmin own + 'all' + sources targeting his domains or their mailboxes + sources created by them + * user own + 'all' + sources targeting him or his domain + * + * @return array{0: string, 1: array} + */ + private function visibility(Context $context): array + { + $username = $context->username; + if ($context->isAdmin()) { + return ['1=1', []]; + } + // Native prepares: a placeholder may appear only once, so the username is bound under one name per use + $managed = "SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = "; + if ($context->isDomainAdmin()) { + return ["(s.`created_by` = :vis_self OR s.`scope` = 'all' + OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t + WHERE t.`source_id` = s.`id` AND t.`domain` IN ($managed :vis_d1))) + OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t JOIN `mailbox` mb ON mb.`username` = t.`username` + WHERE t.`source_id` = s.`id` AND mb.`domain` IN ($managed :vis_d2))) + OR s.`created_by` IN (SELECT `username` FROM `mailbox` WHERE `domain` IN ($managed :vis_d3)))", + ['vis_self' => $username, 'vis_d1' => $username, 'vis_d2' => $username, 'vis_d3' => $username]]; + } + if ($context->isUser()) { + return ["(s.`created_by` = :vis_self OR s.`scope` = 'all' + OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t + WHERE t.`source_id` = s.`id` AND t.`username` = :vis_u1)) + OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t JOIN `mailbox` mb ON mb.`domain` = t.`domain` + WHERE t.`source_id` = s.`id` AND mb.`username` = :vis_u2)))", + ['vis_self' => $username, 'vis_u1' => $username, 'vis_u2' => $username]]; + } + return ['0=1', []]; + } + + private function writeTargets(Source $source): void + { + foreach ($source->domains as $domain) { + $this->query("INSERT INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)", + ['id' => $source->id, 'domain' => $domain]); + } + foreach ($source->users as $username) { + $this->query("INSERT INTO `imapsync_source_user` (`source_id`, `username`) VALUES (:id, :username)", + ['id' => $source->id, 'username' => $username]); + } + $this->query("UPDATE `imapsync_source` SET `scope` = :scope WHERE `id` = :id", ['scope' => $source->scope, 'id' => $source->id]); + } + + private function values(Source $source): array + { + return [ + 'name' => $source->name, + 'description' => $source->description, + 'host1' => $source->host, + 'port1' => $source->port, + 'enc1' => $source->enc, + 'auth_type' => $source->authType, + 'oauth_flow' => $source->oauthFlow, + 'oauth_token_endpoint' => $source->tokenEndpoint, + 'oauth_authorize_endpoint' => $source->authorizeEndpoint, + 'oauth_userinfo_endpoint' => $source->userinfoEndpoint, + 'oauth_client_id' => $source->clientId, + 'oauth_client_secret' => $source->clientSecret, + 'oauth_scope' => $source->oauthScope, + 'oauth_extra_params' => $source->extraParams, + 'active' => $source->active, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Repository/TokenRepository.php b/data/web/inc/lib/mailcow/Imapsync/Repository/TokenRepository.php new file mode 100644 index 000000000..69499b525 --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Repository/TokenRepository.php @@ -0,0 +1,123 @@ +query("SELECT 1 FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN, + ['sid' => $sourceId, 'owner' => $owner, 'user' => $username])->fetchColumn(); + } + + /** + * Stores the token of a finished browser sign-in. An existing refresh token is kept when the + * provider sent none (Microsoft only sends one with offline_access). + */ + public function saveUserToken(OAuthToken $token): void + { + $this->query("INSERT INTO `imapsync_source_oauth_token` + (`source_id`, `owner`, `username`, `access_token`, `refresh_token`, `token_expires`, `last_refresh_error`, `oauth_last_refresh_attempt`) + VALUES (:sid, :owner, :user, :acc, :ref, :exp, NULL, NOW()) + ON DUPLICATE KEY UPDATE + `access_token` = VALUES(`access_token`), + `refresh_token` = IF(VALUES(`refresh_token`) = '', `refresh_token`, VALUES(`refresh_token`)), + `token_expires` = VALUES(`token_expires`), + `last_refresh_error` = NULL, + `oauth_last_refresh_attempt` = NOW()", + $this->key($token) + ['acc' => $token->accessToken, 'ref' => (string)$token->refreshToken, 'exp' => $token->tokenExpires]); + } + + /** Stores a refreshed per-user token and clears its last error */ + public function storeUserRefresh(OAuthToken $token): void + { + $this->query("UPDATE `imapsync_source_oauth_token` SET `access_token` = :acc, `refresh_token` = :ref, + `token_expires` = :exp, `last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE " . self::USER_TOKEN, + $this->key($token) + ['acc' => $token->accessToken, 'ref' => $token->refreshToken, 'exp' => $token->tokenExpires]); + } + + public function storeUserError(OAuthToken $token, string $error): void + { + $this->query("UPDATE `imapsync_source_oauth_token` SET `last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW() + WHERE " . self::USER_TOKEN, $this->key($token) + ['err' => $error]); + } + + public function storeSourceToken(int $sourceId, string $accessToken, int $expires): void + { + $this->query("UPDATE `imapsync_source` SET `oauth_access_token` = :tok, `oauth_token_expires` = :exp, + `oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE `id` = :id", + ['tok' => $accessToken, 'exp' => $expires, 'id' => $sourceId]); + } + + public function storeSourceError(int $sourceId, string $error): void + { + $this->query("UPDATE `imapsync_source` SET `oauth_last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW() + WHERE `id` = :id", ['err' => $error, 'id' => $sourceId]); + } + + /** @return Source[] active client_credentials sources (incl. secrets), each with refreshAttemptAge */ + public function refreshableSources(): array + { + return array_map([Source::class, 'fromRow'], $this->query("SELECT *, + TIMESTAMPDIFF(SECOND, `oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age` + FROM `imapsync_source` + WHERE `active` = 1 AND `auth_type` = 'XOAUTH2' AND `oauth_flow` = 'client_credentials' + ORDER BY `id`")->fetchAll(PDO::FETCH_ASSOC)); + } + + /** @return OAuthToken[] per-user tokens with a refresh token on active authorization_code sources, with refreshAttemptAge */ + public function refreshableUserTokens(): array + { + return array_map([OAuthToken::class, 'fromRow'], $this->query("SELECT t.*, + TIMESTAMPDIFF(SECOND, t.`oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age` + FROM `imapsync_source_oauth_token` t + JOIN `imapsync_source` s ON s.`id` = t.`source_id` + WHERE s.`active` = 1 AND s.`auth_type` = 'XOAUTH2' AND s.`oauth_flow` = 'authorization_code' + AND t.`refresh_token` <> '' + ORDER BY t.`source_id`, t.`owner`, t.`username`")->fetchAll(PDO::FETCH_ASSOC)); + } + + /** Drops a per-user token once no job uses (source, owner, username) anymore */ + public function deleteIfUnused(int $sourceId, string $owner, string $username): void + { + $this->query("DELETE FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN . " + AND NOT EXISTS (SELECT 1 FROM `imapsync` WHERE `source_id` = :sid2 AND `user2` = :owner2 AND `user1` = :user2)", + ['sid' => $sourceId, 'owner' => $owner, 'user' => $username, 'sid2' => $sourceId, 'owner2' => $owner, 'user2' => $username]); + } + + /** Drops per-user tokens that no job uses and that are older than $seconds. Returns the number of rows. */ + public function deleteUnusedOlderThan(int $seconds): int + { + return $this->query("DELETE t FROM `imapsync_source_oauth_token` t + LEFT JOIN `imapsync` i ON i.`source_id` = t.`source_id` AND i.`user2` = t.`owner` AND i.`user1` = t.`username` + WHERE i.`id` IS NULL AND t.`created` < NOW() - INTERVAL :seconds SECOND", ['seconds' => $seconds])->rowCount(); + } + + public function deleteByOwner(string $owner): void + { + $this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `owner` = :owner", ['owner' => $owner]); + } + + public function renameOwner(string $old, string $new): void + { + $this->query("UPDATE `imapsync_source_oauth_token` SET `owner` = :new WHERE `owner` = :old", ['new' => $new, 'old' => $old]); + } + + private function key(OAuthToken $token): array + { + return ['sid' => $token->sourceId, 'owner' => $token->owner, 'user' => $token->username]; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/JobService.php b/data/web/inc/lib/mailcow/Imapsync/Service/JobService.php new file mode 100644 index 000000000..c87cac02c --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Service/JobService.php @@ -0,0 +1,286 @@ +context->hasAcl('syncjobs')) { + throw new AccessDenied(); + } + } + + /** The allowed custom params (option => rule), for the job form */ + public function customParamOptions(): array + { + return $this->allowlist; + } + + /** + * The job when the caller may access its mailbox (incl. password1: never pass the object to the UI, use toArray()). + * Admins may also manage orphaned jobs whose local mailbox no longer exists. + */ + public function get(int $id, bool $withLog = true): ?Job + { + $job = $this->jobs->find($id, $withLog); + return ($job !== null && ($this->context->isAdmin() || $this->context->canAccessMailbox($job->user2))) ? $job : null; + } + + /** @return int[] job ids of a mailbox (default: the caller's own) in queue order; [] without access */ + public function idsForMailbox(mixed $mailbox): array + { + if ($mailbox !== null && filter_var($mailbox, FILTER_VALIDATE_EMAIL)) { + if (!$this->context->canAccessMailbox((string)$mailbox)) { + return []; + } + } else { + $mailbox = $this->context->username; + } + return ($mailbox === null) ? [] : $this->jobs->idsForMailbox((string)$mailbox); + } + + /** Only admins list all jobs at once (incl. orphaned ones) */ + public function canListAll(): bool + { + return $this->context->isAdmin(); + } + + /** @return int[] all job ids in queue order; [] for non-admins */ + public function allIds(): array + { + return $this->canListAll() ? $this->jobs->allIds() : []; + } + + /** @throws UserError */ + public function create(array $input): Job + { + $this->requireAcl(); + $job = new Job(); + $job->user2 = $this->ownerMailbox($input); + // Add keeps its legacy parsing of these attributes (e.g. maxage '1.5' means none, on edit 1); then the edit rules apply + $input['source_id'] = (int)($input['source_id'] ?? 0); + $input['maxage'] = Input::intInRange($input['maxage'] ?? null, 1, 32000) ?? 0; + $input['maxbytespersecond'] = Input::intInRange($input['maxbytespersecond'] ?? null, 1, 125000000) ?? 0; + $this->save($job, null, $input + ['custom_params' => '']); + return $job; + } + + /** @throws UserError */ + public function update(int $id, array $input): Job + { + $this->requireAcl(); + $current = $this->get($id, false) ?? throw new AccessDenied(); + $job = clone $current; + $this->save($job, $current, $input); + return $job; + } + + /** @throws UserError access_denied when the job does not exist or the caller may not access it */ + public function delete(int $id): void + { + $this->requireAcl(); + $job = $this->get($id, false) ?? throw new AccessDenied(); + // prio is a sparse sort key (ties allowed), so no gap needs closing + $this->jobs->delete($id); + $this->tokens->deleteIfUnused($job->sourceId, $job->user2, $job->user1); + } + + /** + * Admin only (C4): moves jobs to the front of the queue. Running jobs are left alone; the runner never resets prio. + * + * @param int[] $ids + * @return array{running: int[], moved: int} + */ + public function runNext(array $ids): array + { + $ids = array_values(array_unique($ids)); + if (!$this->context->isAdmin() || empty($ids)) { + throw new AccessDenied(); + } + return $this->jobs->moveToFront($ids); + } + + /** @throws AccessDenied for non-admins */ + public function getSettings(): Settings + { + if (!$this->context->isAdmin()) { + throw new AccessDenied(); + } + return $this->settings->load(); + } + + /** + * Saves the global settings (admin only). The UI sends the bandwidth limit in KB/s; stored as bytes/s + * like the per-job limit. + * + * @throws UserError + */ + public function saveSettings(array $input): Settings + { + if (!$this->context->isAdmin()) { + throw new AccessDenied(); + } + $maxParallel = Input::intInRange($input['max_parallel'] ?? null, 1, 32); + $kb = $input['max_kb_per_second'] ?? 0; + if ($maxParallel === null) { + throw new UserError('imapsync_max_parallel_invalid'); + } + // 0 (unlimited) .. 10 GiB/s + $kb = Input::intInRange(($kb === '') ? 0 : $kb, 0, 10485760) ?? throw new UserError('imapsync_max_kb_per_second_invalid'); + $settings = new Settings(); + $settings->maxParallel = $maxParallel; + $settings->maxBytesPerSecond = $kb * 1024; + $this->settings->save($settings); + return $settings; + } + + /** + * Applies an add ($current = null) or edit request to $job and stores it. Attributes missing in $input keep + * the value of $job, an empty password keeps the stored one. On edit the source is only checked when the job + * moves to another source, so a domain admin or user can still edit or deactivate a job bound to a source + * he cannot see. + * + * @throws UserError + */ + private function save(Job $job, ?Job $current, array $input): void + { + foreach (self::FLAGS as $flag) { + if (isset($input[$flag])) { + $job->$flag = (int)$input[$flag]; + } + } + foreach (['maxage', 'timeout1', 'timeout2'] as $number) { + if (isset($input[$number]) && $input[$number] != '') { + $job->$number = (int)$input[$number]; + } + } + if (isset($input['maxbytespersecond']) && $input['maxbytespersecond'] != '') { + $job->maxBytesPerSecond = (string)(int)$input['maxbytespersecond']; + } + // last_run/success are reset when present; prio is a global-queue concern that only admins may change + $job->lastRun = isset($input['last_run']) ? null : $job->lastRun; + $job->success = isset($input['success']) ? null : $job->success; + if (isset($input['prio']) && is_numeric($input['prio']) && $this->context->isAdmin()) { + $job->prio = (int)$input['prio']; + } + // An empty user1 keeps the stored one on edit; a new job needs one + $user1 = (isset($input['user1']) && Input::text($input['user1']) !== '') ? Input::text($input['user1']) : $job->user1; + if ($user1 === null || mb_strlen($user1) > 255 || ($current === null && $user1 === '')) { + throw new UserError('imapsync_user1_invalid'); + } + $password = (isset($input['password1']) && is_scalar($input['password1']) && (string)$input['password1'] !== '') + ? (string)$input['password1'] : $job->password1; + $sourceId = (!empty($input['source_id']) && is_scalar($input['source_id'])) ? (int)$input['source_id'] : $job->sourceId; + $sourceChanged = ($current === null || $sourceId !== $current->sourceId); + $tupleChanged = $sourceChanged || $user1 !== $current->user1; + $job->user1 = $user1; + $job->sourceId = $sourceId; + + $source = $sourceChanged ? $this->sources->get($sourceId) : $current->source; + if ($source === null || ($sourceChanged && $source->active !== 1)) { + throw new UserError('imapsync_source_unavailable'); + } + if ($source->usesClientCredentials() && $tupleChanged && !$sourceChanged && !$this->mayUse($source)) { + // The app token reaches every mailbox of the tenant, so only callers that may use the source + // may pick another remote mailbox (user1). Other fields stay editable. + throw new AccessDenied(); + } + $job->password1 = $this->checkAuth($source, $job, $password, $tupleChanged); + if (array_key_exists('custom_params', $input)) { + $job->customParams = CustomParams::toJson($input['custom_params'], $this->allowlist); + } + $job->maxage = Input::intInRange($job->maxage, 1, 32000) ?? 0; + $job->timeout1 = Input::intInRange($job->timeout1, 1, 32000) ?? 600; + $job->timeout2 = Input::intInRange($job->timeout2, 1, 32000) ?? 600; + $job->maxBytesPerSecond = (string)(Input::intInRange($job->maxBytesPerSecond, 1, 125000000) ?? 0); + $exclude = isset($input['exclude']) ? Input::text($input['exclude']) : $job->exclude; + $subfolder2 = isset($input['subfolder2']) ? Input::text($input['subfolder2']) : $job->subfolder2; + $minsInterval = Input::intInRange(!empty($input['mins_interval']) ? $input['mins_interval'] : $job->minsInterval, 1, 43800); + // exclude must be a valid regex, the interval 1..43800 minutes + if ($exclude === null || $subfolder2 === null || $minsInterval === null || @preg_match("/" . $exclude . "/", '') === false) { + throw new AccessDenied(); + } + $job->exclude = $exclude; + $job->subfolder2 = $subfolder2; + $job->minsInterval = $minsInterval; + if ($tupleChanged && $this->jobs->tupleExists($job->sourceId, $job->user2, $job->user1, $current?->id)) { + throw new UserError('object_exists', [htmlspecialchars($source->name . ' / ' . $job->user1)]); + } + if ($current === null) { + $this->jobs->insert($job); + return; + } + $this->jobs->update($job); + // The previous (source, owner, remote user) token is dropped once no job uses it (C1) + if ($tupleChanged) { + $this->tokens->deleteIfUnused($current->sourceId, $current->user2, $current->user1); + } + } + + /** Whether the caller may use the source for new jobs (sees it or may edit it) */ + private function mayUse(Source $source): bool + { + return $this->sources->get((int)$source->id) !== null || $this->sources->canEdit($source); + } + + /** + * Local mailbox of a new job: `username` (needs access), else the caller's own mailbox (users only). + * + * @throws UserError access_denied, no_user_defined + */ + private function ownerMailbox(array $input): string + { + $username = $input['username'] ?? null; + if (is_string($username) && filter_var($username, FILTER_VALIDATE_EMAIL)) { + return $this->context->canAccessMailbox($username) ? $username : throw new AccessDenied(); + } + return $this->context->isUser() ? (string)$this->context->username : throw new UserError('no_user_defined'); + } + + /** + * Returns the password to store. XOAUTH2 sources carry no password (the runner uses an access token); + * an authorization_code source needs the per-user token of (source, owner, remote user) when it is new. + * + * @throws UserError imapsync_source_oauth_connect_required, password_empty + */ + private function checkAuth(Source $source, Job $job, string $password, bool $tupleChanged): string + { + if (!$source->isXOAuth2()) { + return ($password !== '') ? $password : throw new UserError('password_empty'); + } + if ($source->usesAuthorizationCode() && $tupleChanged && !$this->tokens->exists($job->sourceId, $job->user2, $job->user1)) { + throw new UserError('imapsync_source_oauth_connect_required'); + } + return ''; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/OAuthService.php b/data/web/inc/lib/mailcow/Imapsync/Service/OAuthService.php new file mode 100644 index 000000000..aea2a2dbd --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Service/OAuthService.php @@ -0,0 +1,321 @@ +hostname = strtolower(trim($hostname)); + } + + /** + * Redirect URI to register at the provider. Only MAILCOW_HOSTNAME is supported: the popup callback + * needs the session cookie and opener origin of the UI, so the UI must be used on that host. + */ + public function redirectUri(): string + { + return ($this->hostname === '') ? '' : 'https://' . $this->hostname . '/syncjob-oauth'; + } + + /** + * Sets the OAuth settings of a source from request attributes: cleared for password sources, read and + * validated for XOAUTH2 (incl. the endpoint policy C5). Missing attributes keep the value of $source, + * an empty client secret keeps the stored one. Permission checks are the caller's job. + * + * @throws UserError + */ + public function applySettings(Source $source, array $input): void + { + if (!$source->isXOAuth2()) { + $source->oauthFlow = Source::FLOW_CLIENT_CREDENTIALS; + $source->tokenEndpoint = $source->authorizeEndpoint = $source->userinfoEndpoint = null; + $source->clientId = $source->clientSecret = $source->oauthScope = $source->extraParams = null; + return; + } + $source->oauthFlow = (($input['oauth_flow'] ?? $source->oauthFlow) === Source::FLOW_AUTHORIZATION_CODE) + ? Source::FLOW_AUTHORIZATION_CODE : Source::FLOW_CLIENT_CREDENTIALS; + $source->tokenEndpoint = $this->oauthText($input, 'oauth_token_endpoint', $source->tokenEndpoint); + $authorizeEndpoint = $this->oauthText($input, 'oauth_authorize_endpoint', $source->authorizeEndpoint); + $userinfoEndpoint = $this->oauthText($input, 'oauth_userinfo_endpoint', $source->userinfoEndpoint); + $source->clientId = $this->oauthText($input, 'oauth_client_id', $source->clientId); + $source->oauthScope = $this->oauthText($input, 'oauth_scope', $source->oauthScope); + $secret = $input['oauth_client_secret'] ?? ''; + if (!is_scalar($secret)) { + throw new UserError('imapsync_source_oauth_invalid'); + } + $source->clientSecret = ((string)$secret !== '') ? (string)$secret : (string)$source->clientSecret; + $source->extraParams = $this->extraParams(array_key_exists('oauth_extra_params', $input) ? $input['oauth_extra_params'] : $source->extraParams); + + if ($source->tokenEndpoint === '' || $source->clientId === '' || $source->clientSecret === '' || $source->oauthScope === '') { + throw new UserError('imapsync_source_oauth_required'); + } + if (mb_strlen($source->clientId) > 500 || mb_strlen($source->clientSecret) > 500 || mb_strlen($source->oauthScope) > 500) { + throw new UserError('imapsync_source_oauth_invalid'); + } + // C5, checked again before every request. The authorize endpoint is only opened in the browser, + // but must not point users to internal hosts either. + $endpoints = [$source->tokenEndpoint]; + if ($source->usesAuthorizationCode()) { + $endpoints[] = $authorizeEndpoint; + } + if ($userinfoEndpoint !== '') { + $endpoints[] = $userinfoEndpoint; + } + foreach ($endpoints as $endpoint) { + if (!$this->endpointAllowed($endpoint, $source->createdBy)) { + throw new UserError('imapsync_source_oauth_endpoint_invalid'); + } + } + $source->authorizeEndpoint = ($authorizeEndpoint === '') ? null : $authorizeEndpoint; + $source->userinfoEndpoint = ($userinfoEndpoint === '') ? null : $userinfoEndpoint; + } + + /** Provider consent URL for the browser sign-in, or null when the authorize endpoint is not allowed */ + public function authorizeUrl(Source $source, string $state): ?string + { + $endpoint = (string)$source->authorizeEndpoint; + if (!$this->endpointAllowed($endpoint, $source->createdBy)) { + return null; + } + return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query(['client_id' => $source->clientId, + 'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query', + 'scope' => $source->oauthScope, 'state' => $state]); + } + + /** + * Finishes the browser sign-in: exchanges the code, finds the remote identity and stores the token + * for the local mailbox $owner (C1). Returns the remote identity (the job's user1). + * + * @throws UserError token_failed (args: detail for the server log) or no_identity + */ + public function connectUser(Source $source, string $owner, string $code): string + { + try { + $token = $this->requestToken($source, ['grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri()]); + } catch (OAuthException $e) { + throw new UserError('token_failed', ['token exchange failed for source ' . $source->id . ': ' . $e->getMessage()]); + } + $identity = $this->identity($source, $token); + if ($identity === null) { + throw new UserError('no_identity'); + } + try { + $this->tokens->saveUserToken(OAuthToken::fromRow(['source_id' => $source->id, 'owner' => $owner, 'username' => $identity, + 'access_token' => $token['access_token'], 'refresh_token' => $token['refresh_token'] ?? '', + 'token_expires' => time() + (int)$token['expires_in']])); + } catch (PDOException $e) { + throw new UserError('token_failed', ['storing token failed for source ' . $source->id . ': ' . $e->getMessage()]); + } + return $identity; + } + + /** Fetches and stores a new shared token of a client_credentials source (or stores the error); returns the error or null */ + public function refreshSourceToken(Source $source): ?string + { + if (!$source->usesClientCredentials()) { + return 'not refreshable'; + } + try { + $token = $this->requestToken($source, ['grant_type' => 'client_credentials']); + } catch (OAuthException $e) { + $this->tokens->storeSourceError((int)$source->id, $e->getMessage()); + return $e->getMessage(); + } + $this->tokens->storeSourceToken((int)$source->id, $token['access_token'], time() + (int)$token['expires_in']); + return null; + } + + /** Refreshes and stores a per-user token via its refresh token (or stores the error); returns the error or null */ + public function refreshUserToken(Source $source, OAuthToken $token): ?string + { + if (!$source->usesAuthorizationCode() || (string)$token->refreshToken === '') { + return 'not refreshable'; + } + try { + $new = $this->requestToken($source, ['grant_type' => 'refresh_token', 'refresh_token' => $token->refreshToken]); + } catch (OAuthException $e) { + $this->tokens->storeUserError($token, $e->getMessage()); + return $e->getMessage(); + } + $token->accessToken = $new['access_token']; + // Some providers omit a new refresh token on refresh: keep the existing one then + $token->refreshToken = empty($new['refresh_token']) ? $token->refreshToken : $new['refresh_token']; + $token->tokenExpires = time() + (int)$new['expires_in']; + $this->tokens->storeUserRefresh($token); + return null; + } + + /** + * Cron run: refreshes due source tokens, drops unused per-user tokens, refreshes due per-user tokens. + * + * @return array log entries for CRON_LOG + */ + public function refreshDue(): array + { + $log = []; + foreach ($this->tokens->refreshableSources() as $source) { + if ($this->isDue($source->tokenExpires, $source->lastRefreshError, $source->refreshAttemptAge)) { + try { + $error = $this->refreshSourceToken($source); + } catch (Exception $e) { + $error = get_class($e); + } + $label = "source '" . $source->name . "' (id=" . $source->id . ")"; + $log[] = $this->logEntry($error, "Refreshed token for " . $label, "Refresh failed for " . $label); + } + } + $removed = $this->tokens->deleteUnusedOlderThan(self::UNUSED_TOKEN_GRACE); + if ($removed > 0) { + $log[] = ['priority' => 'info', 'message' => "Removed " . $removed . " unused user token(s)"]; + } + foreach ($this->tokens->refreshableUserTokens() as $token) { + if ($this->isDue($token->tokenExpires, $token->lastRefreshError, $token->refreshAttemptAge)) { + $source = null; + try { + $source = $this->sources->find($token->sourceId); + $error = ($source === null) ? 'not refreshable' : $this->refreshUserToken($source, $token); + } catch (Exception $e) { + $error = get_class($e); + } + $label = "user token '" . $token->username . "' (mailbox " . $token->owner . ") on source '" + . ($source->name ?? '') . "' (id=" . $token->sourceId . ")"; + $log[] = $this->logEntry($error, "Refreshed " . $label, "Refresh failed for " . $label); + } + } + return $log; + } + + /** Due when the token expires soon (or is missing) and the last failed attempt is old enough */ + private function isDue(?int $expires, ?string $lastError, ?int $attemptAge): bool + { + return ($expires === null || $expires < time() + self::REFRESH_AHEAD) + && ($lastError === null || $attemptAge === null || $attemptAge > self::RETRY_AFTER); + } + + private function logEntry(?string $error, string $success, string $failure): array + { + return ($error === null) ? ['priority' => 'info', 'message' => $success] + : ['priority' => 'warning', 'message' => $failure . ": " . $error]; + } + + /** + * Endpoint policy (C5). Endpoints of sources not created by an admin must resolve to public addresses; + * admin sources have no address restriction (API key sessions run as admin under the reserved name 'API'). + */ + private function endpointAllowed(string $url, string $createdBy): bool + { + return $this->client->isAllowed($url, $this->isStrict($createdBy)); + } + + private function isStrict(string $createdBy): bool + { + return !($createdBy === 'API' || $this->sources->isSuperAdmin($createdBy)); + } + + /** One OAuth text attribute from the request, or the stored value when the attribute is missing */ + private function oauthText(array $input, string $key, ?string $stored): string + { + $value = array_key_exists($key, $input) ? Input::text($input[$key]) : (string)$stored; + return $value ?? throw new UserError('imapsync_source_oauth_invalid'); + } + + /** + * Extra token request parameters: a JSON object with scalar values, as string or as array + * (after 011-api.js auto-parsing). Returns the JSON to store, null for none. + */ + private function extraParams(mixed $extra): ?string + { + if ($extra === null || (is_string($extra) && trim($extra) === '')) { + $extra = []; + } elseif (is_string($extra)) { + $extra = json_decode($extra, true); + } + $bad = !is_array($extra); + foreach ((array)$extra as $key => $value) { + $bad = $bad || !is_string($key) || $key === '' || !(is_scalar($value) || $value === null); + } + $json = empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($bad || strlen((string)$json) > 4000) { + throw new UserError('imapsync_source_oauth_extra_invalid'); + } + return $json; + } + + /** + * Token endpoint request for every grant type. Stored extra params never override the client and grant fields. + * + * @throws OAuthException + */ + private function requestToken(Source $source, array $grant): array + { + $extra = json_decode((string)$source->extraParams, true); + $post = array_merge(is_array($extra) ? $extra : [], + ['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant); + $token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post); + if (empty($token['access_token']) || !is_string($token['access_token'])) { + throw new OAuthException('malformed token response'); + } + if (!isset($token['expires_in']) || !is_numeric($token['expires_in'])) { + $token['expires_in'] = 3600; + } + if (isset($token['refresh_token']) && !is_string($token['refresh_token'])) { + unset($token['refresh_token']); + } + return $token; + } + + /** Remote identity: id_token claims (received directly from the token endpoint over TLS), else the userinfo endpoint */ + private function identity(Source $source, array $token): ?string + { + $parts = explode('.', is_string($token['id_token'] ?? null) ? $token['id_token'] : ''); + $email = (count($parts) === 3) ? $this->emailClaim(json_decode((string)base64_decode(strtr($parts[1], '-_', '+/')), true)) : null; + if ($email !== null || empty($source->userinfoEndpoint)) { + return $email; + } + try { + return $this->emailClaim($this->client->request($source->userinfoEndpoint, $this->isStrict($source->createdBy), null, + ['Authorization: Bearer ' . $token['access_token']])); + } catch (OAuthException $e) { + return null; + } + } + + private function emailClaim(mixed $claims): ?string + { + foreach (['email', 'preferred_username', 'upn', 'unique_name'] as $key) { + if (is_array($claims) && !empty($claims[$key]) && is_string($claims[$key]) && filter_var($claims[$key], FILTER_VALIDATE_EMAIL)) { + return strtolower($claims[$key]); + } + } + return null; + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php new file mode 100644 index 000000000..22f80f63a --- /dev/null +++ b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php @@ -0,0 +1,347 @@ +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]; + } +} diff --git a/data/web/inc/lib/mailcow/README.md b/data/web/inc/lib/mailcow/README.md new file mode 100644 index 000000000..f66eff55f --- /dev/null +++ b/data/web/inc/lib/mailcow/README.md @@ -0,0 +1,97 @@ +# mailcow PHP classes (`Mailcow\`) + +New backend code of the mailcow UI/API lives here as plain PHP classes, one directory per module. +It replaces the procedural `data/web/inc/functions.*.inc.php` files step by step. There is no framework, +no DI container and no ORM: just small classes, constructor injection and PDO. + +The directory sits below `inc/lib/`, which nginx never serves. Classes are loaded by Composer's PSR-4 +autoloader (`"Mailcow\\": "mailcow/"` in `inc/lib/composer.json`), which every entry point already +includes (`prerequisites.inc.php`, crons, `mailcowauth.php`). + +## Layout + +``` +Core/ shared by all modules + Context.php who is calling: role, username, ACL, access-all; fromSession(), system() + UserError.php expected error with a lang key (+ args), shown as a danger message + AccessDenied.php UserError 'access_denied' + ApiResponse.php collects messages, writes $_SESSION['return'] (the only class that does) + Input.php helpers for loosely typed request values + Repository.php base class of all repositories: PDO, query(), insertRow(), updateRow(), transaction() +/ + Module.php composition root: create(PDO, Context, array $config) wires everything; + fromGlobals() is the legacy entry point for procedural callers + Model/ plain typed objects (fromRow(), toArray() for the API shape), no DB + Repository/ SQL only (prepared statements); takes and returns models + Service/ use cases, business rules, permissions (Context), transactions via repositories + Http/ outbound I/O (e.g. OAuth requests) + Controller/ request in, messages out: adapter for json_api.php and pages +``` + +Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules. + +## Layers and rules + +- **Model:** typed public properties, `fromRow()` from a DB row, `toArray()` for the API format (keys and + order are the API contract). Secrets stay in the model but never in `toArray()`. No getters/setters. +- **Repository:** extends `Core\Repository`; the only place with SQL (prepared statements via `query()`). + No permission decisions (a visibility filter that is pure SQL may take the `Context`). A method that writes + several tables wraps them in `transaction()`, which joins a transaction the caller already runs. +- **Service:** one use case per public method, working on single objects. Checks permissions with the + `Context` and throws `UserError` / `AccessDenied` with a lang key. No `$_SESSION`, no `global`, no output. + When add and edit share their rules, use one private routine for both (e.g. `JobService::save()`, + `SourceService::applyBasics()`): missing attributes keep the current value, a new object starts with defaults. +- **Controller:** normalises request input (batch ids, attribute merging), loops over batch ids, calls + services, turns `UserError` into messages via `ApiResponse` and returns what `json_api.php` passes to + `process_*_return()`. The API format (routes, JSON, message keys and order) stays as it was. Each write + method is a few lines on top of two private helpers: `handle()` (log entry, error mapping, flush) and + `batch()` (one message per id). +- **View:** Twig templates, unchanged. + +## Conventions + +- `declare(strict_types=1);` in every file; typed properties, parameters and return types; constructor + property promotion; `public readonly` properties instead of getters for values that never change. +- Class files contain only declarations (no code at file level), one class per file. +- Constructor injection. Global state (`$pdo`, `$_SESSION`, config arrays, `getenv()`) is only read at the + entry points and passed to `Module::create()`. Procedural legacy callers (`json_api.php`, pages, + `functions.*.inc.php`) use `Module::fromGlobals(?Context $context = null)`, the one documented + place in the module that reads the globals, instead of repeating the config lines; entry points that build + their own state (crons, CLI) call `create()` directly, e.g. with `Context::system()`. + `ApiResponse::flush()` is the one class that writes `$_SESSION['return']`. +- No interfaces until there are two implementations; no closures as injected dependencies (local callbacks + such as `transaction(fn ...)` or the controller's `handle()` are fine); small methods. +- Docblocks only where they add information (rules, API contract, non-obvious behaviour), not to repeat + the signature. +- Classes are not `final`, so PHPUnit can mock them. I/O that tests must replace sits in protected methods + (see `Imapsync/Http/OAuthClient::send()`). +- Messages: services throw, controllers report. The `msg` is a lang key of the matching section + (`success` / `danger`), optionally with arguments: `new UserError('object_exists', [$name])`. +- Logs: the `log` part of a message is `[, , , ]` as before; request + keys holding secrets are masked by `ApiResponse` (configure them in the module). +- A database error (`PDOException`) stops the call with one `mysql_error` message, like the legacy code. +- Naming: `Mailcow\\{Model,Repository,Service,Http,Controller}\`. + +## Converting a legacy `functions.X.inc.php` + +1. Capture the current API behaviour (E2E runs, raw JSON responses, DB state). +2. Build the module as above: models, repositories, services, controller, `Module::create()`. +3. Switch the callers (`json_api.php`, pages, crons, other function files) to the module. +4. Delete the function file and its `require` lines. +5. Compare the API output with step 1; the format must stay the same. + +Large files (e.g. `functions.mailbox.inc.php`) are converted one area at a time (aliases, domains, ...), +each as its own module. Until `functions.inc.php` is converted, `Context::canAccessMailbox()` / +`canAccessDomain()` call the legacy `hasMailboxObjectAccess()` / `hasDomainAccess()`. + +## Development + +- Unit tests: `tests/php//...` (dev-only, never deployed), run with + `helper-scripts/dev_tests/run_php_unit_tests.sh`. Services are tested with mocked repositories; + repositories are covered by the E2E suites (MariaDB-specific SQL). +- Autoloader: after adding the PSR-4 mapping nothing needs regenerating for new classes. If you ever run + `composer dump-autoload` (inside the php-fpm container, `-w /web/inc/lib`), never use `-o`, `-a` or + `--apcu`, and commit only the changed `autoload_*.php` files. +- No dev packages in `inc/lib/composer.json`. +- opcache runs with `validate_timestamps=0`: restart php-fpm after changing PHP files + (`docker compose restart php-fpm-mailcow`). diff --git a/data/web/inc/lib/vendor/composer/autoload_psr4.php b/data/web/inc/lib/vendor/composer/autoload_psr4.php index 236034cf0..afc9a1dd1 100644 --- a/data/web/inc/lib/vendor/composer/autoload_psr4.php +++ b/data/web/inc/lib/vendor/composer/autoload_psr4.php @@ -25,6 +25,7 @@ return array( 'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'), 'MatthiasMullie\\PathConverter\\' => array($vendorDir . '/matthiasmullie/path-converter/src'), 'MatthiasMullie\\Minify\\' => array($vendorDir . '/matthiasmullie/minify/src'), + 'Mailcow\\' => array($baseDir . '/mailcow'), 'League\\OAuth2\\Client\\' => array($vendorDir . '/league/oauth2-client/src'), 'LdapRecord\\' => array($vendorDir . '/directorytree/ldaprecord/src'), 'Illuminate\\Support\\' => array($vendorDir . '/illuminate/collections', $vendorDir . '/illuminate/macroable', $vendorDir . '/illuminate/conditionable'), diff --git a/data/web/inc/lib/vendor/composer/autoload_static.php b/data/web/inc/lib/vendor/composer/autoload_static.php index f66ad0c71..f0a06b7cf 100644 --- a/data/web/inc/lib/vendor/composer/autoload_static.php +++ b/data/web/inc/lib/vendor/composer/autoload_static.php @@ -58,6 +58,7 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b array ( 'MatthiasMullie\\PathConverter\\' => 29, 'MatthiasMullie\\Minify\\' => 22, + 'Mailcow\\' => 8, ), 'L' => array ( @@ -177,6 +178,10 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b array ( 0 => __DIR__ . '/..' . '/matthiasmullie/minify/src', ), + 'Mailcow\\' => + array ( + 0 => __DIR__ . '/../..' . '/mailcow', + ), 'League\\OAuth2\\Client\\' => array ( 0 => __DIR__ . '/..' . '/league/oauth2-client/src',