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 1/5] [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', From e756b7857ee935215accf0132f979a4d7eceedfd Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:11:43 +0200 Subject: [PATCH 2/5] [Web][Tests] Add PHPUnit setup and imapsync module tests --- .gitignore | 4 +- .../dev_tests/run_php_unit_tests.sh | 15 + tests/php/Core/ApiResponseTest.php | 92 + tests/php/Core/ContextTest.php | 84 + .../Imapsync/Controller/ApiControllerTest.php | 306 +++ tests/php/Imapsync/Http/OAuthClientTest.php | 202 ++ tests/php/Imapsync/Model/CustomParamsTest.php | 146 ++ tests/php/Imapsync/Service/JobServiceTest.php | 371 ++++ .../php/Imapsync/Service/OAuthServiceTest.php | 260 +++ .../Imapsync/Service/SourceServiceTest.php | 506 +++++ tests/php/Support/FakeContext.php | 56 + tests/php/Support/FakeOAuthClient.php | 35 + tests/php/Support/LegacyAclCalls.php | 13 + tests/php/Support/legacy_acl_functions.php | 22 + tests/php/bootstrap.php | 8 + tests/php/composer.json | 20 + tests/php/composer.lock | 1786 +++++++++++++++++ tests/php/phpunit.xml.dist | 23 + 18 files changed, 3948 insertions(+), 1 deletion(-) create mode 100755 helper-scripts/dev_tests/run_php_unit_tests.sh create mode 100644 tests/php/Core/ApiResponseTest.php create mode 100644 tests/php/Core/ContextTest.php create mode 100644 tests/php/Imapsync/Controller/ApiControllerTest.php create mode 100644 tests/php/Imapsync/Http/OAuthClientTest.php create mode 100644 tests/php/Imapsync/Model/CustomParamsTest.php create mode 100644 tests/php/Imapsync/Service/JobServiceTest.php create mode 100644 tests/php/Imapsync/Service/OAuthServiceTest.php create mode 100644 tests/php/Imapsync/Service/SourceServiceTest.php create mode 100644 tests/php/Support/FakeContext.php create mode 100644 tests/php/Support/FakeOAuthClient.php create mode 100644 tests/php/Support/LegacyAclCalls.php create mode 100644 tests/php/Support/legacy_acl_functions.php create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/composer.json create mode 100644 tests/php/composer.lock create mode 100644 tests/php/phpunit.xml.dist diff --git a/.gitignore b/.gitignore index f7c6f512e..b5988ba55 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,6 @@ refresh_images.sh update_diffs/ create_cold_standby.sh !data/conf/nginx/mailcow_auth.conf -data/conf/postfix/postfix-tlspol \ No newline at end of file +data/conf/postfix/postfix-tlspol +tests/php/vendor/ +tests/php/.phpunit.cache/ diff --git a/helper-scripts/dev_tests/run_php_unit_tests.sh b/helper-scripts/dev_tests/run_php_unit_tests.sh new file mode 100755 index 000000000..91f4e5022 --- /dev/null +++ b/helper-scripts/dev_tests/run_php_unit_tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Runs the PHPUnit tests of data/web/inc/lib/mailcow (tests/php) in throwaway containers. +# Dev only: PHPUnit is installed into tests/php/vendor, never into the production vendor directory. +# Usage: helper-scripts/dev_tests/run_php_unit_tests.sh [phpunit arguments, e.g. --filter JobServiceTest] + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TESTS_DIR="${REPO_DIR}/tests/php" + +docker run --rm -u "$(id -u):$(id -g)" -v "${TESTS_DIR}:/app" -w /app composer:2 \ + install --no-interaction --no-progress --quiet + +docker run --rm -u "$(id -u):$(id -g)" -v "${REPO_DIR}:/app" -w /app/tests/php php:8.2-cli \ + php vendor/bin/phpunit "$@" diff --git a/tests/php/Core/ApiResponseTest.php b/tests/php/Core/ApiResponseTest.php new file mode 100644 index 000000000..c6c4a7503 --- /dev/null +++ b/tests/php/Core/ApiResponseTest.php @@ -0,0 +1,92 @@ +assertTrue($response->success(['mailbox_modified', 'a@example.org'], ['syncjob', 'add', 'job', []])); + $this->assertFalse($response->danger('access_denied', ['syncjob', 'edit', 'job', []])); + + $this->assertSame([ + ['type' => 'success', 'log' => ['syncjob', 'add', 'job', []], 'msg' => ['mailbox_modified', 'a@example.org']], + ['type' => 'danger', 'log' => ['syncjob', 'edit', 'job', []], 'msg' => 'access_denied'], + ], $response->messages()); + } + + public function testSecretsAreMaskedInArrayLogParts(): void + { + $response = new ApiResponse(['password1', 'oauth_client_secret']); + + $response->success('ok', ['syncjob', 'add', 'job', [ + 'password1' => 'secret', + 'oauth_client_secret' => '', + 'user1' => 'remote', + 'nested' => ['password1' => 'kept'], + ]]); + + $this->assertSame(['syncjob', 'add', 'job', [ + 'password1' => '*', + 'oauth_client_secret' => '', + 'user1' => 'remote', + 'nested' => ['password1' => 'kept'], + ]], $response->messages()[0]['log']); + } + + public function testNullSecretStaysNull(): void + { + $response = new ApiResponse(['password1']); + + $response->danger('x', ['syncjob', 'add', 'job', ['password1' => null]]); + + $this->assertSame(['password1' => null], $response->messages()[0]['log'][3]); + } + + public function testUserErrorBecomesDangerMessage(): void + { + $response = new ApiResponse(); + + $this->assertFalse($response->error(new UserError('object_exists', ['name']), ['l'])); + $response->error(new AccessDenied(), ['l']); + + $this->assertSame(['object_exists', 'name'], $response->messages()[0]['msg']); + $this->assertSame('danger', $response->messages()[0]['type']); + $this->assertSame('access_denied', $response->messages()[1]['msg']); + } + + public function testFlushAppendsToSessionAndStartsOver(): void + { + $_SESSION['return'] = [['type' => 'success', 'log' => [], 'msg' => 'earlier']]; + $response = new ApiResponse(); + $response->success('first', ['l']); + $response->danger('second', ['l']); + + $response->flush(); + $response->flush(); + + $this->assertSame(['earlier', 'first', 'second'], array_column($_SESSION['return'], 'msg')); + $this->assertSame([], $response->messages()); + } + + public function testFlushWithoutMessagesLeavesSessionUntouched(): void + { + (new ApiResponse())->flush(); + + $this->assertArrayNotHasKey('return', $_SESSION); + } +} diff --git a/tests/php/Core/ContextTest.php b/tests/php/Core/ContextTest.php new file mode 100644 index 000000000..654065887 --- /dev/null +++ b/tests/php/Core/ContextTest.php @@ -0,0 +1,84 @@ + 'domainadmin', + 'mailcow_cc_username' => 'da', + 'acl' => ['syncjobs' => '1', 'spam_score' => '0'], + 'access_all_exception' => '1', + ]); + + $this->assertSame('domainadmin', $context->role); + $this->assertSame('da', $context->username); + $this->assertTrue($context->isDomainAdmin()); + $this->assertFalse($context->isAdmin()); + $this->assertFalse($context->isUser()); + $this->assertTrue($context->hasAcl('syncjobs')); + $this->assertFalse($context->hasAcl('spam_score')); + $this->assertFalse($context->hasAcl('missing')); + $this->assertTrue($context->accessAll); + } + + public function testEmptySessionHasNoRights(): void + { + $context = Context::fromSession([]); + + $this->assertSame('', $context->role); + $this->assertNull($context->username); + $this->assertFalse($context->isAdmin() || $context->isDomainAdmin() || $context->isUser()); + $this->assertFalse($context->hasAcl('syncjobs')); + $this->assertFalse($context->accessAll); + } + + public function testAclAndAccessAllAcceptIntegerOneLikeTheLegacyCode(): void + { + $context = Context::fromSession(['mailcow_cc_role' => 'user', 'acl' => ['syncjobs' => 1], 'access_all_exception' => 1]); + + $this->assertTrue($context->hasAcl('syncjobs')); + $this->assertTrue($context->accessAll); + $this->assertFalse(Context::fromSession(['access_all_exception' => '0'])->accessAll); + $this->assertFalse(Context::fromSession(['acl' => 'not an array'])->hasAcl('syncjobs')); + } + + public function testSystemContextIsNoAccount(): void + { + $context = Context::system(); + + $this->assertSame('system', $context->role); + $this->assertNull($context->username); + $this->assertFalse($context->isAdmin()); + $this->assertFalse($context->hasAcl('syncjobs')); + } + + public function testBridgePassesUsernameAndRoleToTheLegacyFunctions(): void + { + $context = new Context('user', 'user@example.org'); + + $this->assertTrue($context->canAccessMailbox('allowed@example.org')); + $this->assertFalse($context->canAccessMailbox('other@example.org')); + $this->assertTrue($context->canAccessDomain('example.org')); + $this->assertSame([ + ['mailbox', 'user@example.org', 'user', 'allowed@example.org'], + ['mailbox', 'user@example.org', 'user', 'other@example.org'], + ['domain', 'user@example.org', 'user', 'example.org'], + ], LegacyAclCalls::$calls); + } +} diff --git a/tests/php/Imapsync/Controller/ApiControllerTest.php b/tests/php/Imapsync/Controller/ApiControllerTest.php new file mode 100644 index 000000000..114e29ba9 --- /dev/null +++ b/tests/php/Imapsync/Controller/ApiControllerTest.php @@ -0,0 +1,306 @@ +sources = $this->createMock(SourceService::class); + $this->jobs = $this->createMock(JobService::class); + $this->api = new ImapsyncApiController($this->sources, $this->jobs, new ApiResponse(['password1', 'oauth_client_secret'])); + } + + private static function job(string $user2 = 'local@example.org', array $row = []): Job + { + return Job::fromRow($row + ['id' => 1, 'user2' => $user2, 'user1' => 'remote', 'source_id' => 5]); + } + + private static function source(string $name = 'src'): Source + { + return Source::fromRow(['id' => 5, 'name' => $name]); + } + + /** + * Messages written to the session as [type, msg]. + */ + private function messages(): array + { + return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []); + } + + public function testAddJobSuccessIsLoggedWithoutSecrets(): void + { + $this->jobs->method('create')->willReturn(self::job('new@example.org')); + + $this->assertTrue($this->api->addJob(['username' => 'new@example.org', 'password1' => 'pw'])); + + $this->assertSame([[ + 'type' => 'success', + 'log' => ['syncjob', 'add', 'job', ['username' => 'new@example.org', 'password1' => '*']], + 'msg' => ['mailbox_modified', 'new@example.org'], + ]], $_SESSION['return']); + } + + public function testUserErrorAndDatabaseErrorBecomeDangerMessages(): void + { + $this->jobs->method('create')->willReturnOnConsecutiveCalls( + $this->throwException(new UserError('object_exists', ['src / remote'])), + $this->throwException(new PDOException('deadlock')) + ); + + $this->assertFalse($this->api->addJob([])); + $this->assertFalse($this->api->addJob([])); + + $this->assertSame([['danger', ['object_exists', 'src / remote']], ['danger', ['mysql_error', 'deadlock']]], $this->messages()); + } + + public function testAddSource(): void + { + $this->sources->method('create')->willReturn(self::source('new')); + + $this->assertTrue($this->api->addSource(['name' => 'new', 'oauth_client_secret' => 's'])); + + $this->assertSame([['success', ['imapsync_source_added', 'new']]], $this->messages()); + $this->assertSame('*', $_SESSION['return'][0]['log'][3]['oauth_client_secret']); + } + + public function testEditJobsKeepsBatchMessageOrder(): void + { + $this->jobs->method('update')->willReturnCallback(function (int $id, array $data): Job { + if ($id === 2) { + throw new UserError('imapsync_source_unavailable'); + } + return self::job('mb' . $id . '@example.org'); + }); + + $this->assertTrue($this->api->editJobs([1, 'x', 2, '3', 1.5], ['active' => 0])); + + $this->assertSame([ + ['success', ['mailbox_modified', 'mb1@example.org']], + ['danger', 'access_denied'], + ['danger', 'imapsync_source_unavailable'], + ['success', ['mailbox_modified', 'mb3@example.org']], + ['danger', 'access_denied'], + ], $this->messages()); + $this->assertSame(['syncjob', 'edit', 'job', ['id' => [1, 'x', 2, '3', 1.5], 'active' => 0]], $_SESSION['return'][0]['log']); + } + + public function testEditJobsIdInAttributesWinsLikeArrayMerge(): void + { + $this->jobs->expects($this->once())->method('update')->with(7, ['id' => 7])->willReturn(self::job()); + + $this->assertTrue($this->api->editJobs([1, 2], ['id' => 7])); + } + + public function testEditJobsWithoutAclIsOneRejection(): void + { + $this->jobs->method('requireAcl')->willThrowException(new AccessDenied()); + $this->jobs->expects($this->never())->method('update'); + + $this->assertFalse($this->api->editJobs([1, 2], [])); + $this->assertSame([['danger', 'access_denied']], $this->messages()); + } + + public function testDatabaseErrorStopsTheBatch(): void + { + $this->jobs->method('update')->willReturnOnConsecutiveCalls(self::job(), $this->throwException(new PDOException('gone'))); + + $this->assertFalse($this->api->editJobs([1, 2, 3], [])); + $this->assertSame([['success', ['mailbox_modified', 'local@example.org']], ['danger', ['mysql_error', 'gone']]], $this->messages()); + } + + public function testEditJobsWithoutItemsDoesNothing(): void + { + $this->jobs->expects($this->never())->method('update'); + + $this->assertTrue($this->api->editJobs(null, ['active' => 1])); + $this->assertSame([], $this->messages()); + } + + public function testRunNextReportsRunningJobsThenTheMove(): void + { + $this->jobs->expects($this->once())->method('runNext')->with([4, 1, 2])->willReturn(['running' => [4], 'moved' => 2]); + + $this->assertTrue($this->api->runNext(['4', 'x', 1.7, 2])); + $this->assertSame([['danger', ['imapsync_job_running', 4]], ['success', ['imapsync_run_next_done', 2]]], $this->messages()); + $this->assertSame(['syncjob', 'edit', 'run_next', ['id' => ['4', 'x', 1.7, 2]]], $_SESSION['return'][0]['log']); + } + + public function testRunNextWithoutMoveReturnsFalse(): void + { + $this->jobs->method('runNext')->willReturnOnConsecutiveCalls(['running' => [], 'moved' => 0], $this->throwException(new AccessDenied())); + + $this->assertFalse($this->api->runNext([9])); + $this->assertSame([], $this->messages()); + $this->assertFalse($this->api->runNext([9])); + $this->assertSame([['danger', 'access_denied']], $this->messages()); + } + + public function testEditSourcesSkipsNonNumericIds(): void + { + $this->sources->method('update')->willReturnCallback(function (int $id): Source { + if ($id === 3) { + throw new AccessDenied(); + } + return self::source('s' . $id); + }); + + $this->assertTrue($this->api->editSources(['a', 2, 3, '1.5'], ['name' => 'x'])); + $this->assertSame([['success', ['imapsync_source_modified', 's2']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages()); + } + + public function testEditSourcesNeedsAnEditor(): void + { + $this->sources->method('requireEditor')->willThrowException(new AccessDenied()); + + $this->assertFalse($this->api->editSources([1], [])); + $this->assertSame([['danger', 'access_denied']], $this->messages()); + } + + public function testRefreshTokenUsesTheFirstId(): void + { + $this->sources->expects($this->once())->method('refreshToken')->with(6)->willReturn(self::source('m365')); + + $this->assertTrue($this->api->refreshSourceToken(['6', '7'])); + $this->assertFalse($this->api->refreshSourceToken([])); + $this->assertFalse($this->api->refreshSourceToken(null)); + + $this->assertSame([['success', ['imapsync_source_token_refreshed', 'm365']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages()); + $this->assertSame(['syncjob', 'edit', 'refresh_token', ['id' => ['6', '7']]], $_SESSION['return'][0]['log']); + } + + public function testEditSettings(): void + { + $this->jobs->method('saveSettings')->willReturnOnConsecutiveCalls(new Settings(), $this->throwException(new UserError('imapsync_max_parallel_invalid'))); + + $this->assertTrue($this->api->editSettings(['max_parallel' => 2])); + $this->assertFalse($this->api->editSettings(['max_parallel' => 0])); + $this->assertSame([['success', 'max_parallel_saved'], ['danger', 'imapsync_max_parallel_invalid']], $this->messages()); + $this->assertSame(['syncjob', 'edit', 'settings', ['max_parallel' => 2]], $_SESSION['return'][0]['log']); + } + + public function testDeleteJobsUsesTheRequestIdInTheMessage(): void + { + $this->jobs->method('delete')->willReturnCallback(function (int $id): void { + if ($id === 8) { + throw new AccessDenied(); + } + }); + + $this->assertTrue($this->api->deleteJobs(['5', 8, 'x'])); + $this->assertSame([['success', ['deleted_syncjob', '5']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages()); + } + + public function testDeleteSources(): void + { + $this->sources->method('delete')->willReturnCallback(function (int $id): Source { + if ($id === 2) { + throw new UserError('imapsync_source_in_use', ['busy']); + } + return self::source('gone'); + }); + + $this->assertTrue($this->api->deleteSources([1, 'x', 2])); + $this->assertSame([['success', ['imapsync_source_deleted', 'gone']], ['danger', ['imapsync_source_in_use', 'busy']]], $this->messages()); + + $_SESSION = []; + $this->sources->method('requireAcl')->willThrowException(new AccessDenied()); + $this->assertFalse($this->api->deleteSources([1])); + $this->assertSame([['danger', 'access_denied']], $this->messages()); + } + + public function testReadRoutes(): void + { + $source = self::source(); + $this->jobs->method('customParamOptions')->willReturn(['debug' => false]); + $this->jobs->method('getSettings')->willReturn(new Settings()); + $this->sources->method('visibleIds')->willReturn([5, 6]); + $this->sources->method('get')->willReturnMap([[5, $source], [6, null]]); + $this->sources->method('toArray')->with($source)->willReturn(['id' => 5]); + + $this->assertSame(['debug' => false], $this->api->getOptions()); + $this->assertSame(['max_parallel' => 1, 'max_bytes_per_second' => 0, 'max_kb_per_second' => 0], $this->api->getSettings()); + $this->assertSame([['id' => 5]], $this->api->getSources()); + $this->assertSame(['id' => 5], $this->api->getSource('5')); + $this->assertSame([], $this->api->getSource('abc')); + $this->assertSame([], $this->api->getSource(6)); + $this->assertSame([], $this->messages()); + } + + public function testSettingsAreEmptyForNonAdmins(): void + { + $this->jobs->method('getSettings')->willThrowException(new AccessDenied()); + + $this->assertSame([], $this->api->getSettings()); + } + + public function testJobRoutesHonourNoLog(): void + { + $job = self::job('local@example.org', ['returned_text' => 'log text', 'password1' => 'secret']); + $this->jobs->method('idsForMailbox')->with('local@example.org')->willReturn([1]); + $this->jobs->expects($this->exactly(2))->method('get') + ->willReturnCallback(fn(int $id, bool $withLog): Job => $job); + + $withLog = $this->api->getJobs('local@example.org', null); + $withoutLog = $this->api->getJob('1', 'no_log,with_password'); + + $this->assertSame('log text', $withLog[0]['log']); + $this->assertSame('', $withoutLog['log']); + $this->assertArrayNotHasKey('password1', $withLog[0]); + $this->assertArrayNotHasKey('returned_text', $withLog[0]); + } + + public function testAllJobsForAdminsAndOthers(): void + { + $jobs = [ + 1 => self::job('a@example.org', ['id' => 1, 'prio' => 0, 'last_run' => '2026-10-01 10:00:00']), + 2 => self::job('b@example.org', ['id' => 2, 'prio' => 0]), + 3 => self::job('a@example.org', ['id' => 3, 'prio' => 5, 'last_run' => '2026-10-02 10:00:00']), + 4 => self::job('b@example.org', ['id' => 4, 'prio' => 0, 'last_run' => '2026-09-01 10:00:00']), + ]; + $this->jobs->method('canListAll')->willReturnOnConsecutiveCalls(true, false); + $this->jobs->method('allIds')->willReturn([3, 2, 4, 1]); + $this->jobs->method('idsForMailbox')->willReturnMap([['a@example.org', [3, 1]], ['b@example.org', [2, 4]]]); + $this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => $jobs[$id] ?? null); + + $this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs(null, []), 'id')); + $this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs('no_log', ['a@example.org', 'b@example.org']), 'id')); + } + + public function testAccountChangedOutsideTransactionReportsDatabaseErrors(): void + { + $this->sources->expects($this->once())->method('onAccountRenamed')->with('old@example.org', 'new@example.org'); + $this->sources->method('onAccountDeleted')->willThrowException(new PDOException('locked')); + + $this->api->accountChanged('old@example.org', 'new@example.org'); + $this->assertSame([], $this->messages()); + + $this->api->accountChanged('old@example.org'); + $this->assertSame([[ + 'type' => 'danger', + 'log' => ['imapsync_account_changed', 'old@example.org', null], + 'msg' => ['mysql_error', 'locked'], + ]], $_SESSION['return']); + } +} diff --git a/tests/php/Imapsync/Http/OAuthClientTest.php b/tests/php/Imapsync/Http/OAuthClientTest.php new file mode 100644 index 000000000..5564a9f74 --- /dev/null +++ b/tests/php/Imapsync/Http/OAuthClientTest.php @@ -0,0 +1,202 @@ +client = new FakeOAuthClient(); + } + + public static function malformedUrls(): array + { + return [ + 'http' => ['http://login.example.org/token'], + 'file' => ['file:///etc/passwd'], + 'gopher' => ['gopher://login.example.org/'], + 'userinfo' => ['https://user:pass@login.example.org/token'], + 'user only' => ['https://user@login.example.org/token'], + 'no host' => ['https:///token'], + 'invalid hostname' => ['https://login_example.org/token'], + 'space' => ['https://login.example.org/a b'], + 'control character' => ["https://login.example.org/\x01"], + 'too long' => ['https://login.example.org/' . str_repeat('a', 480)], + 'empty' => [''], + ]; + } + + #[DataProvider('malformedUrls')] + public function testMalformedUrlsAreNeverAllowed(string $url): void + { + $this->assertFalse($this->client->isAllowed($url, false)); + $this->assertFalse($this->client->isAllowed($url, true)); + } + + public function testNonStrictModeDoesNotResolve(): void + { + $this->assertTrue($this->client->isAllowed('https://login.example.org/token', false)); + $this->assertTrue($this->client->isAllowed('https://localhost.localdomain:8443/token?x=1', false)); + $this->assertSame([], $this->client->resolved); + } + + public static function blockedAddresses(): array + { + return [ + 'loopback' => ['127.0.0.1'], + 'this network' => ['0.0.0.0'], + 'private 10/8' => ['10.1.2.3'], + 'private 172.16/12' => ['172.31.255.255'], + 'private 192.168/16' => ['192.168.0.1'], + 'link-local' => ['169.254.169.254'], + 'CGNAT start' => ['100.64.0.0'], + 'CGNAT end' => ['100.127.255.255'], + 'benchmarking start' => ['198.18.0.1'], + 'benchmarking end' => ['198.19.255.255'], + 'multicast' => ['224.0.0.1'], + 'reserved 240/4' => ['240.0.0.1'], + 'broadcast' => ['255.255.255.255'], + 'IPv6 loopback' => ['::1'], + 'IPv6 unspecified' => ['::'], + 'IPv6 link-local' => ['fe80::1'], + 'IPv6 ULA' => ['fd00::1'], + 'IPv6 multicast' => ['ff02::1'], + 'IPv4-mapped' => ['::ffff:127.0.0.1'], + 'NAT64' => ['64:ff9b::a00:1'], + '6to4' => ['2002:a00:1::1'], + 'Teredo' => ['2001:0:4136:e378::1'], + 'documentation' => ['2001:db8::1'], + 'outside 2000::/3' => ['4000::1'], + ]; + } + + #[DataProvider('blockedAddresses')] + public function testStrictModeBlocksNonPublicAddresses(string $ip): void + { + $this->client->addresses['login.example.org'] = [$ip]; + + $this->assertFalse($this->client->isAllowed('https://login.example.org/token', true)); + } + + public static function publicAddresses(): array + { + return [ + 'IPv4' => ['8.8.8.8'], + 'just below CGNAT' => ['100.63.255.255'], + 'just above CGNAT' => ['100.128.0.1'], + 'just below benchmarking' => ['198.17.255.255'], + 'just above benchmarking' => ['198.20.0.1'], + 'just below multicast' => ['223.255.255.254'], + 'IPv6 global' => ['2606:4700:4700::1111'], + 'IPv6 global 2a00' => ['2a00:1450:4001::1'], + ]; + } + + #[DataProvider('publicAddresses')] + public function testStrictModeAllowsPublicAddresses(string $ip): void + { + $this->client->addresses['login.example.org'] = [$ip]; + + $this->assertTrue($this->client->isAllowed('https://login.example.org/token', true)); + } + + public function testStrictModeNeedsEveryAddressPublic(): void + { + $this->client->addresses['login.example.org'] = ['8.8.8.8', '10.0.0.1']; + $this->assertFalse($this->client->isAllowed('https://login.example.org/token', true)); + + $this->client->addresses['login.example.org'] = []; + $this->assertFalse($this->client->isAllowed('https://login.example.org/token', true)); + } + + public function testStrictRequestIsPinnedToTheCheckedAddresses(): void + { + $this->client->addresses['login.example.org'] = ['8.8.8.8', '8.8.8.8', '2606:4700:4700::1111']; + $this->client->response = ['{"access_token":"t"}', 200]; + + $this->client->request('https://Login.Example.org:8443/token', true, ['grant_type' => 'client_credentials']); + + $this->assertSame(['login.example.org:8443:8.8.8.8,[2606:4700:4700::1111]'], $this->client->sent[0][CURLOPT_RESOLVE]); + } + + public function testRequestOptionsAllowHttpsOnlyWithoutRedirects(): void + { + $this->client->response = ['{"ok":true}', 200]; + + $result = $this->client->request('https://login.example.org/token', false, ['a' => 'b c', 'd' => 'e'], ['Authorization: Bearer x']); + + $options = $this->client->sent[0]; + $this->assertSame(['ok' => true], $result); + $this->assertArrayNotHasKey(CURLOPT_RESOLVE, $options); + $this->assertSame(CURLPROTO_HTTPS, $options[CURLOPT_PROTOCOLS]); + $this->assertSame(CURLPROTO_HTTPS, $options[CURLOPT_REDIR_PROTOCOLS]); + $this->assertFalse($options[CURLOPT_FOLLOWLOCATION]); + $this->assertTrue($options[CURLOPT_SSL_VERIFYPEER]); + $this->assertSame(2, $options[CURLOPT_SSL_VERIFYHOST]); + $this->assertSame(1048576, $options[CURLOPT_MAXFILESIZE]); + $this->assertTrue($options[CURLOPT_POST]); + $this->assertSame('a=b+c&d=e', $options[CURLOPT_POSTFIELDS]); + $this->assertSame(['Accept: application/json', 'Authorization: Bearer x'], $options[CURLOPT_HTTPHEADER]); + } + + public function testGetRequestHasNoBody(): void + { + $this->client->request('https://login.example.org/userinfo', false); + + $this->assertArrayNotHasKey(CURLOPT_POST, $this->client->sent[0]); + } + + public function testDisallowedEndpointIsNeverRequested(): void + { + $this->client->addresses['internal.example.org'] = ['10.0.0.1']; + + $this->assertSame('endpoint not allowed', $this->requestError('https://internal.example.org/token', true)); + $this->assertSame([], $this->client->sent); + } + + public static function failedResponses(): array + { + return [ + 'connection error' => [[false, 0], 'connection failed'], + 'provider error fields' => [['{"error":"invalid_client","error_description":"Bad secret","trace":"x"}', 401], 'HTTP 401: invalid_client - Bad secret'], + 'html body is never shown' => [['internal details', 500], 'HTTP 500'], + 'non-scalar error fields' => [['{"error":{"code":1}}', 400], 'HTTP 400'], + '2xx without JSON object' => [['"just a string"', 200], 'HTTP 200: malformed response'], + 'tags and control characters' => [["{\"error\":\"\\nbad\"}", 400], 'HTTP 400: x bad'], + ]; + } + + #[DataProvider('failedResponses')] + public function testErrorsAreSanitised(array $response, string $error): void + { + $this->client->response = $response; + + $this->assertSame($error, $this->requestError('https://login.example.org/token', false)); + } + + public function testErrorTextIsCutTo200Characters(): void + { + $this->client->response = [json_encode(['error_description' => str_repeat('x', 500)]), 400]; + + $this->assertSame(200, mb_strlen($this->requestError('https://login.example.org/token', false))); + } + + private function requestError(string $url, bool $strict): string + { + try { + $this->client->request($url, $strict, []); + } catch (OAuthException $e) { + return $e->getMessage(); + } + $this->fail('OAuthException expected'); + } +} diff --git a/tests/php/Imapsync/Model/CustomParamsTest.php b/tests/php/Imapsync/Model/CustomParamsTest.php new file mode 100644 index 000000000..924db327b --- /dev/null +++ b/tests/php/Imapsync/Model/CustomParamsTest.php @@ -0,0 +1,146 @@ + false, + 'folder' => true, + 'sslargs1' => '^SSL_verify_mode=[01]$', + 'tilde' => 'a~b', + 'broken' => 5, + ]; + + public function testEmptyInputMeansNoOptions(): void + { + $this->assertSame('[]', CustomParams::toJson(null, self::ALLOWLIST)); + $this->assertSame('[]', CustomParams::toJson('', self::ALLOWLIST)); + $this->assertSame('[]', CustomParams::toJson([], self::ALLOWLIST)); + } + + public function testValidPairsAreNormalisedAndEncoded(): void + { + $json = CustomParams::toJson([ + ['o' => ' --Debug ', 'v' => ''], + ['o' => 'folder', 'v' => "INBOX/Sub\0 äö"], + ['o' => 'sslargs1', 'v' => 'SSL_verify_mode=0'], + ['o' => '', 'v' => ' '], + ], self::ALLOWLIST); + + $this->assertSame([ + ['o' => 'debug', 'v' => ''], + ['o' => 'folder', 'v' => 'INBOX/Sub äö'], + ['o' => 'sslargs1', 'v' => 'SSL_verify_mode=0'], + ], json_decode($json, true)); + $this->assertSame('[{"o":"debug","v":""},{"o":"folder","v":"INBOX/Sub äö"},{"o":"sslargs1","v":"SSL_verify_mode=0"}]', $json); + } + + public function testFlagWithWhitespaceValueIsStoredWithoutValue(): void + { + $this->assertSame('[{"o":"debug","v":""}]', CustomParams::toJson([['o' => 'debug', 'v' => ' ']], self::ALLOWLIST)); + } + + public static function invalidInputs(): array + { + return [ + 'string instead of list' => ['--debug', 'imapsync_custom_params_format'], + 'pair is no array' => [['debug'], 'imapsync_custom_params_format'], + 'option is array' => [[['o' => ['x'], 'v' => '']], 'imapsync_custom_params_format'], + 'value is array' => [[['o' => 'folder', 'v' => ['x']]], 'imapsync_custom_params_format'], + 'unknown option' => [[['o' => 'logfile', 'v' => '/etc/passwd']], ['imapsync_custom_param_invalid', '<b>logfile']], + 'value without option' => [[['o' => '', 'v' => 'x']], ['imapsync_custom_param_invalid', '']], + 'flag with value' => [[['o' => 'debug', 'v' => '1']], ['imapsync_custom_param_value', 'debug']], + 'value option without value' => [[['o' => 'folder', 'v' => ' ']], ['imapsync_custom_param_value', 'folder']], + 'regex mismatch' => [[['o' => 'sslargs1', 'v' => 'SSL_ca_file=/etc/x']], ['imapsync_custom_param_value', 'sslargs1']], + 'regex must match whole value' => [[['o' => 'sslargs1', 'v' => "SSL_verify_mode=1\n"]], ['imapsync_custom_param_value', 'sslargs1']], + 'regex empty value' => [[['o' => 'sslargs1', 'v' => '']], ['imapsync_custom_param_value', 'sslargs1']], + 'malformed rule' => [[['o' => 'broken', 'v' => 'x']], ['imapsync_custom_param_invalid', 'broken']], + 'value too long' => [[['o' => 'folder', 'v' => str_repeat('a', 1001)]], 'imapsync_custom_params_format'], + ]; + } + + #[DataProvider('invalidInputs')] + public function testInvalidInputIsRejected(mixed $input, string|array $msg): void + { + try { + CustomParams::toJson($input, self::ALLOWLIST); + $this->fail('UserError expected'); + } catch (UserError $e) { + $this->assertSame($msg, $e->msg()); + } + } + + public function testRegexDelimiterInRuleIsEscaped(): void + { + $this->assertSame('[{"o":"tilde","v":"a~b"}]', CustomParams::toJson([['o' => 'tilde', 'v' => 'a~b']], self::ALLOWLIST)); + } + + public function testAtMostOneHundredPairs(): void + { + $pairs = array_fill(0, 100, ['o' => 'debug', 'v' => '']); + $this->assertCount(100, json_decode(CustomParams::toJson($pairs, self::ALLOWLIST), true)); + + $this->expectException(UserError::class); + $this->expectExceptionMessage('imapsync_custom_params_format'); + CustomParams::toJson(array_merge($pairs, [['o' => 'debug', 'v' => '']]), self::ALLOWLIST); + } + + public static function storedValues(): array + { + return [ + 'pairs' => ['[{"o":"folder","v":"a\\/b"}]'], + 'NULL' => [null], + 'old command line string' => ['--debug --folder INBOX'], + 'JSON object' => ['{"o":"debug"}'], + 'list of non-pairs' => ['[1,"x",{"v":"no option"}]'], + ]; + } + + #[DataProvider('storedValues')] + public function testStoredValuesPassThroughUnchanged(?string $stored): void + { + $this->assertSame($stored, Job::fromRow(['id' => 1, 'custom_params' => $stored])->toArray()['custom_params']); + } + + public function testAllowlistDropsMalformedEntries(): void + { + $allowlist = CustomParams::allowlist([ + '--Folder' => true, + 'debug' => false, + 'sslargs1' => '^x$', + '1abc' => true, + 'a-b' => true, + 'empty' => '', + 'number' => 1, + ], ['default' => true]); + + $this->assertSame(['folder' => true, 'debug' => false, 'sslargs1' => '^x$'], $allowlist); + } + + public function testAllowlistFallsBackToDefaults(): void + { + $this->assertSame(['default' => true], CustomParams::allowlist([], ['default' => true])); + $this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true])); + } + + public function testOldWhitelistFormatIsIgnored(): void + { + $errorLog = ini_set('error_log', '/dev/null'); + try { + $allowlist = CustomParams::allowlist(['whitelist' => ['debug'], 'folder' => true], ['default' => true]); + } finally { + ini_set('error_log', (string)$errorLog); + } + + $this->assertSame(['folder' => true], $allowlist); + } +} diff --git a/tests/php/Imapsync/Service/JobServiceTest.php b/tests/php/Imapsync/Service/JobServiceTest.php new file mode 100644 index 000000000..c2d4d4431 --- /dev/null +++ b/tests/php/Imapsync/Service/JobServiceTest.php @@ -0,0 +1,371 @@ + false, 'folder' => true]; + + private JobRepository&MockObject $jobs; + private SourceService&MockObject $sources; + private TokenRepository&MockObject $tokens; + private SettingsRepository&MockObject $settings; + + protected function setUp(): void + { + $this->jobs = $this->createMock(JobRepository::class); + $this->sources = $this->createMock(SourceService::class); + $this->tokens = $this->createMock(TokenRepository::class); + $this->settings = $this->createMock(SettingsRepository::class); + } + + private function service(Context $context): JobService + { + return new JobService($this->jobs, $this->sources, $this->tokens, $this->settings, $context, self::ALLOWLIST); + } + + private static function source(array $row = []): Source + { + return Source::fromRow($row + ['id' => 5, 'name' => 'src', 'created_by' => 'admin', 'host1' => 'imap.example.org', + 'port1' => 993, 'enc1' => 'SSL', 'auth_type' => 'PLAIN', 'active' => 1]); + } + + private static function storedJob(array $row = [], array $source = []): Job + { + $source += ['source_name' => 'src', 'source_auth_type' => 'PLAIN', 'source_oauth_flow' => 'client_credentials', + 'source_created_by' => 'admin', 'source_active' => 1]; + return Job::fromRow($row + $source + [ + 'id' => 9, 'user2' => 'local@example.org', 'user1' => 'remote@example.net', 'password1' => 'stored-pw', + 'source_id' => 5, 'mins_interval' => 20, 'maxage' => 0, 'timeout1' => 600, 'timeout2' => 600, + 'maxbytespersecond' => '0', 'exclude' => '', 'subfolder2' => '', 'custom_params' => '[{"o":"debug","v":""}]', + 'last_run' => '2026-10-01 10:00:00', 'success' => 1, 'prio' => 3, 'active' => 1, + ]); + } + + private static function jobInput(array $override = []): array + { + return $override + ['username' => 'local@example.org', 'user1' => 'remote@example.net', 'password1' => 'pw', + 'source_id' => 5, 'mins_interval' => 20]; + } + + private function expectUserError(string|array $msg, callable $call): void + { + try { + $call(); + $this->fail('UserError ' . json_encode($msg) . ' expected'); + } catch (UserError $e) { + $this->assertSame($msg, $e->msg()); + } + } + + public function testCreateNeedsAcl(): void + { + $this->jobs->expects($this->never())->method('insert'); + + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org', []))->create(self::jobInput())); + } + + public function testOwnerMailboxRules(): void + { + $this->expectUserError('no_user_defined', fn() => $this->service(FakeContext::domainAdmin())->create(self::jobInput(['username' => null]))); + $this->expectUserError('no_user_defined', fn() => $this->service(FakeContext::admin())->create(self::jobInput(['username' => 'not-a-mailbox']))); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org'))->create(self::jobInput())); + } + + public function testCreateStoresJobWithDefaults(): void + { + $this->sources->method('get')->with(5)->willReturn(self::source()); + $this->jobs->expects($this->once())->method('insert')->with($this->callback(function (Job $job): bool { + return $job->user2 === 'u@example.org' && $job->user1 === 'remote@example.net' && $job->password1 === 'pw' + && $job->maxage === 0 && $job->timeout1 === 600 && $job->timeout2 === 600 && $job->maxBytesPerSecond === '0' + && $job->minsInterval === 20 && $job->active === 0 && $job->customParams === '[{"o":"folder","v":"INBOX"}]'; + })); + + $job = $this->service(FakeContext::user('u@example.org'))->create(self::jobInput([ + 'username' => null, 'user1' => ' remote@example.net ', 'maxage' => 'abc', 'timeout1' => 0, 'maxbytespersecond' => 200000000, + 'custom_params' => [['o' => 'folder', 'v' => 'INBOX']], + ])); + + $this->assertSame('u@example.org', $job->user2); + } + + public function testCreateKeepsValidNumbers(): void + { + $this->sources->method('get')->willReturn(self::source()); + $this->jobs->expects($this->once())->method('insert')->with($this->callback( + fn(Job $job): bool => $job->maxage === 30 && $job->timeout1 === 120 && $job->maxBytesPerSecond === '5000' && $job->minsInterval === 43800 + )); + + $this->service(FakeContext::admin())->create(self::jobInput(['maxage' => '30', 'timeout1' => '120', 'maxbytespersecond' => '5000', 'mins_interval' => 43800])); + } + + public static function invalidCreates(): array + { + return [ + 'user1 missing' => [['user1' => ''], 'imapsync_user1_invalid'], + 'user1 array' => [['user1' => ['x']], 'imapsync_user1_invalid'], + 'user1 too long' => [['user1' => str_repeat('u', 256)], 'imapsync_user1_invalid'], + 'password missing' => [['password1' => ''], 'password_empty'], + 'password array' => [['password1' => ['x']], 'password_empty'], + 'custom params string' => [['custom_params' => '--debug'], 'imapsync_custom_params_format'], + 'custom param unknown' => [['custom_params' => [['o' => 'logfile', 'v' => 'x']]], ['imapsync_custom_param_invalid', 'logfile']], + 'exclude array' => [['exclude' => ['x']], 'access_denied'], + 'interval 0' => [['mins_interval' => 0], 'access_denied'], + 'interval too large' => [['mins_interval' => 43801], 'access_denied'], + 'exclude no regex' => [['exclude' => '(unclosed'], 'access_denied'], + 'user1 checked before password' => [['user1' => '', 'password1' => ''], 'imapsync_user1_invalid'], + ]; + } + + #[DataProvider('invalidCreates')] + public function testCreateValidation(array $override, string|array $msg): void + { + $this->sources->method('get')->willReturn(self::source()); + $this->jobs->expects($this->never())->method('insert'); + + $this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create(self::jobInput($override))); + } + + public function testCreateNeedsVisibleActiveSource(): void + { + $this->sources->method('get')->willReturnOnConsecutiveCalls(null, self::source(['active' => 0])); + $service = $this->service(FakeContext::admin()); + + $this->expectUserError('imapsync_source_unavailable', fn() => $service->create(self::jobInput())); + $this->expectUserError('imapsync_source_unavailable', fn() => $service->create(self::jobInput())); + } + + public function testDuplicateTupleIsRejected(): void + { + $this->sources->method('get')->willReturn(self::source(['name' => ''])); + $this->jobs->method('tupleExists')->with(5, 'local@example.org', 'remote@example.net', null)->willReturn(true); + + $this->expectUserError(['object_exists', '<src> / remote@example.net'], fn() => $this->service(FakeContext::admin())->create(self::jobInput())); + } + + public function testAuthorizationCodeJobNeedsTokenOfOwnerAndRemoteUser(): void + { + $this->sources->method('get')->willReturn(self::source(['auth_type' => 'XOAUTH2', 'oauth_flow' => 'authorization_code'])); + $this->tokens->method('exists')->with(5, 'local@example.org', 'remote@example.net')->willReturnOnConsecutiveCalls(false, true); + $this->jobs->expects($this->once())->method('insert')->with($this->callback(fn(Job $job): bool => $job->password1 === '')); + $service = $this->service(FakeContext::admin()); + + $this->expectUserError('imapsync_source_oauth_connect_required', fn() => $service->create(self::jobInput())); + $service->create(self::jobInput()); + } + + public function testClientCredentialsJobStoresNoPassword(): void + { + $this->sources->method('get')->willReturn(self::source(['auth_type' => 'XOAUTH2', 'oauth_flow' => 'client_credentials'])); + $this->tokens->expects($this->never())->method('exists'); + $this->jobs->expects($this->once())->method('insert')->with($this->callback(fn(Job $job): bool => $job->password1 === '')); + + $this->service(FakeContext::admin())->create(self::jobInput(['password1' => ''])); + } + + public function testUpdateNeedsAccessToTheJob(): void + { + $this->jobs->method('find')->willReturnOnConsecutiveCalls(null, self::storedJob()); + $this->jobs->expects($this->never())->method('update'); + $service = $this->service(FakeContext::user('u@example.org')); + + $this->expectUserError('access_denied', fn() => $service->update(9, [])); + $this->expectUserError('access_denied', fn() => $service->update(9, [])); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('local@example.org', []))->update(9, [])); + } + + public function testEmptyPasswordKeepsTheStoredOne(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $stored = []; + $this->jobs->method('update')->willReturnCallback(function (Job $job) use (&$stored): void { + $stored[] = $job->password1; + }); + $service = $this->service(FakeContext::user('local@example.org')); + + $service->update(9, ['password1' => '']); + $service->update(9, ['password1' => ['x']]); + $service->update(9, ['password1' => 'new']); + + $this->assertSame(['stored-pw', 'stored-pw', 'new'], $stored); + } + + public function testMissingAttributesKeepTheirValue(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $this->jobs->expects($this->once())->method('update')->with($this->callback(function (Job $job): bool { + return $job->active === 0 && $job->user1 === 'remote@example.net' && $job->minsInterval === 20 && $job->prio === 3 + && $job->customParams === '[{"o":"debug","v":""}]' && $job->lastRun === null && $job->success === 1 + && $job->maxage === 7 && $job->timeout1 === 600; + })); + $this->sources->expects($this->never())->method('get'); + $this->tokens->expects($this->never())->method('deleteIfUnused'); + + $this->service(FakeContext::user('local@example.org'))->update(9, [ + 'active' => '0', 'user1' => '', 'mins_interval' => '', 'prio' => 99, 'last_run' => '', 'maxage' => '7', 'timeout1' => '', + ]); + } + + public function testOnlyAdminsChangePrio(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $this->jobs->expects($this->once())->method('update')->with($this->callback(fn(Job $job): bool => $job->prio === 99)); + + $this->service(FakeContext::admin())->update(9, ['prio' => '99']); + } + + public function testJobOnInvisibleSourceStaysEditable(): void + { + $this->jobs->method('find')->willReturn(self::storedJob([], ['source_created_by' => 'someone-else', 'source_active' => 0])); + $this->sources->expects($this->never())->method('get'); + $this->jobs->expects($this->once())->method('update'); + + $this->service(FakeContext::user('local@example.org'))->update(9, ['active' => 0]); + } + + public function testMovingToAnotherSourceNeedsVisibleActiveSource(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $this->sources->method('get')->with(6)->willReturnOnConsecutiveCalls(null, self::source(['id' => 6, 'active' => 0])); + $service = $this->service(FakeContext::user('local@example.org')); + + $this->expectUserError('imapsync_source_unavailable', fn() => $service->update(9, ['source_id' => 6])); + $this->expectUserError('imapsync_source_unavailable', fn() => $service->update(9, ['source_id' => 6])); + } + + public function testMovingToXOAuth2SourceWipesPasswordAndChecksToken(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $this->sources->method('get')->willReturn(self::source(['id' => 6, 'auth_type' => 'XOAUTH2', 'oauth_flow' => 'authorization_code'])); + $this->tokens->method('exists')->with(6, 'local@example.org', 'remote@example.net')->willReturnOnConsecutiveCalls(false, true); + $this->jobs->expects($this->once())->method('update')->with($this->callback(fn(Job $job): bool => $job->password1 === '' && $job->sourceId === 6)); + $this->tokens->expects($this->once())->method('deleteIfUnused')->with(5, 'local@example.org', 'remote@example.net'); + $service = $this->service(FakeContext::user('local@example.org')); + + $this->expectUserError('imapsync_source_oauth_connect_required', fn() => $service->update(9, ['source_id' => 6])); + $service->update(9, ['source_id' => 6]); + } + + public function testSwitchingFromXOAuth2NeedsPassword(): void + { + $this->jobs->method('find')->willReturn(self::storedJob(['password1' => ''])); + $this->sources->method('get')->willReturn(self::source(['id' => 6])); + + $this->expectUserError('password_empty', fn() => $this->service(FakeContext::user('local@example.org'))->update(9, ['source_id' => 6])); + } + + public function testRemoteUserOfClientCredentialsSourceNeedsSourceAccess(): void + { + $job = self::storedJob(['password1' => ''], ['source_auth_type' => 'XOAUTH2', 'source_oauth_flow' => 'client_credentials', + 'source_created_by' => 'admin']); + $this->jobs->method('find')->willReturn($job); + $this->sources->method('get')->with(5)->willReturnOnConsecutiveCalls(null, self::source()); + $this->sources->method('canEdit')->willReturn(false); + $this->jobs->expects($this->once())->method('update'); + $service = $this->service(FakeContext::user('local@example.org')); + + $this->expectUserError('access_denied', fn() => $service->update(9, ['user1' => 'ceo@example.net'])); + $service->update(9, ['user1' => 'ceo@example.net']); + } + + public function testChangedTupleMustStayUnique(): void + { + $this->jobs->method('find')->willReturn(self::storedJob()); + $this->jobs->method('tupleExists')->with(5, 'local@example.org', 'other@example.net', 9)->willReturn(true); + + $this->expectUserError(['object_exists', 'src / other@example.net'], + fn() => $this->service(FakeContext::user('local@example.org'))->update(9, ['user1' => 'other@example.net'])); + } + + public function testDeleteDropsUnusedToken(): void + { + $this->jobs->method('find')->willReturnOnConsecutiveCalls(self::storedJob(['user2' => 'x@example.org']), self::storedJob()); + $this->jobs->expects($this->once())->method('delete')->with(9); + $this->tokens->expects($this->once())->method('deleteIfUnused')->with(5, 'local@example.org', 'remote@example.net'); + $service = $this->service(FakeContext::user('local@example.org')); + + $this->expectUserError('access_denied', fn() => $service->delete(9)); + $service->delete(9); + } + + public function testAdminsManageOrphanedJobs(): void + { + $this->jobs->method('find')->willReturn(self::storedJob(['user2' => 'deleted@example.org'])); + + $this->assertNotNull($this->service(new FakeContext('admin', 'admin'))->get(9)); + $this->assertNull($this->service(FakeContext::domainAdmin())->get(9)); + } + + public function testJobListsFollowMailboxAccess(): void + { + $this->jobs->method('idsForMailbox')->willReturnMap([['u@example.org', [1, 2]], ['m@example.org', [3]]]); + $this->jobs->method('allIds')->willReturn([3, 1, 2]); + $user = $this->service(FakeContext::user('u@example.org')); + + $this->assertSame([1, 2], $user->idsForMailbox(null)); + $this->assertSame([1, 2], $user->idsForMailbox('not-an-address')); + $this->assertSame([], $user->idsForMailbox('m@example.org')); + $this->assertSame([3], $this->service(FakeContext::domainAdmin(['m@example.org']))->idsForMailbox('m@example.org')); + $this->assertSame([], $this->service(new FakeContext('', null))->idsForMailbox(null)); + $this->assertSame([], $user->allIds()); + $this->assertSame([3, 1, 2], $this->service(FakeContext::admin())->allIds()); + } + + public function testRunNextIsAdminOnly(): void + { + $this->jobs->expects($this->once())->method('moveToFront')->with([4, 2])->willReturn(['running' => [], 'moved' => 2]); + + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->runNext([4])); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::admin())->runNext([])); + $this->assertSame(['running' => [], 'moved' => 2], $this->service(FakeContext::admin())->runNext([4, 2, 4])); + } + + public static function invalidSettings(): array + { + return [ + 'parallel 0' => [['max_parallel' => 0], 'imapsync_max_parallel_invalid'], + 'parallel 33' => [['max_parallel' => 33], 'imapsync_max_parallel_invalid'], + 'parallel text' => [['max_parallel' => 'many', 'max_kb_per_second' => -1], 'imapsync_max_parallel_invalid'], + 'kb negative' => [['max_parallel' => 2, 'max_kb_per_second' => -1], 'imapsync_max_kb_per_second_invalid'], + 'kb too large' => [['max_parallel' => 2, 'max_kb_per_second' => 10485761], 'imapsync_max_kb_per_second_invalid'], + ]; + } + + #[DataProvider('invalidSettings')] + public function testSettingsValidation(array $input, string $msg): void + { + $this->settings->expects($this->never())->method('save'); + + $this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->saveSettings($input)); + } + + public function testSettingsAreStoredInBytes(): void + { + $this->settings->expects($this->exactly(2))->method('save')->with($this->callback( + fn(Settings $s): bool => $s->maxParallel === 4 && in_array($s->maxBytesPerSecond, [0, 2048], true) + )); + $service = $this->service(FakeContext::admin()); + + $this->assertSame(2048, $service->saveSettings(['max_parallel' => '4', 'max_kb_per_second' => '2'])->maxBytesPerSecond); + $this->assertSame(0, $service->saveSettings(['max_parallel' => 4, 'max_kb_per_second' => ''])->maxBytesPerSecond); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->saveSettings(['max_parallel' => 1])); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->getSettings()); + } +} diff --git a/tests/php/Imapsync/Service/OAuthServiceTest.php b/tests/php/Imapsync/Service/OAuthServiceTest.php new file mode 100644 index 000000000..d18b739bb --- /dev/null +++ b/tests/php/Imapsync/Service/OAuthServiceTest.php @@ -0,0 +1,260 @@ +sources = $this->createMock(SourceRepository::class); + $this->tokens = $this->createMock(TokenRepository::class); + $this->client = $this->createMock(OAuthClient::class); + } + + private function service(string $hostname = 'Mail.Example.org '): OAuthService + { + return new OAuthService($this->sources, $this->tokens, $this->client, $hostname); + } + + private static function source(array $row = []): Source + { + return Source::fromRow($row + [ + 'id' => 4, 'name' => 'm365', 'created_by' => 'da', 'host1' => 'outlook.office365.com', 'port1' => 993, + 'auth_type' => 'XOAUTH2', 'oauth_flow' => 'client_credentials', 'active' => 1, + 'oauth_token_endpoint' => 'https://login.example.org/token', 'oauth_authorize_endpoint' => 'https://login.example.org/authorize?tenant=x', + 'oauth_userinfo_endpoint' => 'https://login.example.org/userinfo', + 'oauth_client_id' => 'client', 'oauth_client_secret' => 'secret', 'oauth_scope' => 'scope', + ]); + } + + private static function userToken(array $row = []): OAuthToken + { + return OAuthToken::fromRow($row + ['source_id' => 4, 'owner' => 'local@example.org', 'username' => 'remote@example.net', + 'refresh_token' => 'old-refresh', 'access_token' => 'old-access']); + } + + public function testRedirectUriUsesMailcowHostnameOnly(): void + { + $this->assertSame('https://mail.example.org/syncjob-oauth', $this->service()->redirectUri()); + $this->assertSame('', $this->service('')->redirectUri()); + } + + public function testEndpointsOfAdminSourcesAreNotAddressRestricted(): void + { + $this->sources->method('isSuperAdmin')->willReturnMap([['admin', true], ['da', false]]); + $this->client->expects($this->exactly(3))->method('isAllowed') + ->willReturnCallback(fn(string $url, bool $strict): bool => !$strict); + $service = $this->service(); + + foreach (['admin' => true, 'API' => true, 'da' => false] as $createdBy => $allowed) { + $source = self::source(['created_by' => $createdBy, 'oauth_flow' => 'authorization_code']); + $this->assertSame($allowed, $service->authorizeUrl($source, 'state') !== null, $createdBy); + } + } + + public function testAuthorizeUrl(): void + { + $this->client->method('isAllowed')->willReturnOnConsecutiveCalls(true, false); + $source = self::source(['oauth_flow' => 'authorization_code']); + + $this->assertSame('https://login.example.org/authorize?tenant=x&client_id=client&response_type=code' + . '&redirect_uri=https%3A%2F%2Fmail.example.org%2Fsyncjob-oauth&response_mode=query&scope=scope&state=abc', + $this->service()->authorizeUrl($source, 'abc')); + $this->assertNull($this->service()->authorizeUrl($source, 'abc')); + } + + public function testClientCredentialsRefreshStoresToken(): void + { + $this->client->expects($this->once())->method('request')->with( + 'https://login.example.org/token', + true, + ['resource' => 'r', 'client_id' => 'client', 'client_secret' => 'secret', 'scope' => 'scope', 'grant_type' => 'client_credentials'] + )->willReturn(['access_token' => 'new', 'expires_in' => '120']); + $this->tokens->expects($this->once())->method('storeSourceToken')->with(4, 'new', $this->callback( + fn(int $expires): bool => abs($expires - (time() + 120)) <= 2 + )); + + // Stored extra params never override the client and grant fields + $source = self::source(['oauth_extra_params' => '{"resource":"r","client_id":"evil","grant_type":"password"}']); + $this->assertNull($this->service()->refreshSourceToken($source)); + } + + public function testFailedRefreshStoresTheSanitisedError(): void + { + $this->client->method('request')->willThrowException(new OAuthException('HTTP 401: invalid_client')); + $this->tokens->expects($this->once())->method('storeSourceError')->with(4, 'HTTP 401: invalid_client'); + $this->tokens->expects($this->never())->method('storeSourceToken'); + + $this->assertSame('HTTP 401: invalid_client', $this->service()->refreshSourceToken(self::source())); + } + + public function testTokenResponseWithoutAccessTokenIsMalformed(): void + { + $this->client->method('request')->willReturn(['access_token' => ['x']]); + $this->tokens->expects($this->once())->method('storeSourceError')->with(4, 'malformed token response'); + + $this->assertSame('malformed token response', $this->service()->refreshSourceToken(self::source())); + } + + public function testOnlyClientCredentialsSourcesHaveASourceToken(): void + { + $this->client->expects($this->never())->method('request'); + + $this->assertSame('not refreshable', $this->service()->refreshSourceToken(self::source(['oauth_flow' => 'authorization_code']))); + } + + public function testUserRefreshKeepsRefreshTokenWhenProviderSendsNone(): void + { + $source = self::source(['oauth_flow' => 'authorization_code']); + $this->client->method('request')->willReturnOnConsecutiveCalls( + ['access_token' => 'a1'], + ['access_token' => 'a2', 'refresh_token' => 'r2', 'expires_in' => 60] + ); + $stored = []; + $this->tokens->method('storeUserRefresh')->willReturnCallback(function (OAuthToken $t) use (&$stored): void { + $stored[] = [$t->accessToken, $t->refreshToken, $t->tokenExpires - time()]; + }); + + $this->assertNull($this->service()->refreshUserToken($source, self::userToken())); + $this->assertNull($this->service()->refreshUserToken($source, self::userToken())); + + $this->assertEqualsWithDelta([['a1', 'old-refresh', 3600], ['a2', 'r2', 60]], $stored, 2); + } + + public function testUserRefreshFailureIsStoredOnTheToken(): void + { + $this->client->method('request')->willThrowException(new OAuthException('HTTP 400: invalid_grant')); + $this->tokens->expects($this->once())->method('storeUserError')->with($this->isInstanceOf(OAuthToken::class), 'HTTP 400: invalid_grant'); + + $this->assertSame('HTTP 400: invalid_grant', + $this->service()->refreshUserToken(self::source(['oauth_flow' => 'authorization_code']), self::userToken())); + $this->assertSame('not refreshable', + $this->service()->refreshUserToken(self::source(['oauth_flow' => 'authorization_code']), self::userToken(['refresh_token' => '']))); + } + + public function testRefreshDueAppliesExpiryAndRetryRules(): void + { + $soon = time() + 60; + $this->tokens->method('refreshableSources')->willReturn([ + self::source(['id' => 1, 'name' => 'no token']), + self::source(['id' => 2, 'name' => 'valid', 'oauth_token_expires' => time() + 3600]), + self::source(['id' => 3, 'name' => 'failed recently', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 900]), + self::source(['id' => 4, 'name' => 'failed long ago', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 901]), + ]); + $this->tokens->method('deleteUnusedOlderThan')->with(86400)->willReturn(0); + $this->tokens->method('refreshableUserTokens')->willReturn([]); + $this->client->expects($this->exactly(2))->method('request')->willReturnOnConsecutiveCalls( + ['access_token' => 't'], + $this->throwException(new OAuthException('HTTP 401: invalid_client')) + ); + + $this->assertSame([ + ['priority' => 'info', 'message' => "Refreshed token for source 'no token' (id=1)"], + ['priority' => 'warning', 'message' => "Refresh failed for source 'failed long ago' (id=4): HTTP 401: invalid_client"], + ], $this->service()->refreshDue()); + } + + public function testRefreshDueCleansUpAndRefreshesUserTokens(): void + { + $source = self::source(['oauth_flow' => 'authorization_code']); + $this->tokens->method('refreshableSources')->willReturn([]); + $this->tokens->method('deleteUnusedOlderThan')->willReturn(2); + $this->tokens->method('refreshableUserTokens')->willReturn([ + self::userToken(), + self::userToken(['username' => 'fresh@example.net', 'token_expires' => time() + 3600]), + self::userToken(['username' => 'db@example.net']), + self::userToken(['source_id' => 99, 'username' => 'orphan@example.net']), + ]); + $this->sources->method('find')->willReturnMap([[4, $source], [99, null]]); + $this->client->method('request')->willReturn(['access_token' => 'new']); + $this->tokens->method('storeUserRefresh')->willReturnOnConsecutiveCalls(null, $this->throwException(new PDOException('lost'))); + + $this->assertSame([ + ['priority' => 'info', 'message' => 'Removed 2 unused user token(s)'], + ['priority' => 'info', 'message' => "Refreshed user token 'remote@example.net' (mailbox local@example.org) on source 'm365' (id=4)"], + ['priority' => 'warning', 'message' => "Refresh failed for user token 'db@example.net' (mailbox local@example.org) on source 'm365' (id=4): PDOException"], + ['priority' => 'warning', 'message' => "Refresh failed for user token 'orphan@example.net' (mailbox local@example.org) on source '' (id=99): not refreshable"], + ], $this->service()->refreshDue()); + } + + public function testConnectUserTakesIdentityFromIdToken(): void + { + $claims = rtrim(strtr(base64_encode(json_encode(['preferred_username' => 'not an address', 'upn' => 'Remote@Example.NET'])), '+/', '-_'), '='); + $this->client->expects($this->once())->method('request')->with( + 'https://login.example.org/token', + true, + $this->callback(fn(array $post): bool => $post['grant_type'] === 'authorization_code' && $post['code'] === 'c0de' + && $post['redirect_uri'] === 'https://mail.example.org/syncjob-oauth') + )->willReturn(['access_token' => 'acc', 'expires_in' => 100, 'id_token' => 'h.' . $claims . '.s']); + $this->tokens->expects($this->once())->method('saveUserToken')->with($this->callback( + fn(OAuthToken $t): bool => $t->sourceId === 4 && $t->owner === 'local@example.org' && $t->username === 'remote@example.net' + && $t->accessToken === 'acc' && $t->refreshToken === '' + )); + + $this->assertSame('remote@example.net', $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'local@example.org', 'c0de')); + } + + public function testConnectUserFallsBackToUserinfo(): void + { + $this->client->method('request')->willReturnOnConsecutiveCalls( + ['access_token' => 'acc', 'refresh_token' => 'ref'], + ['email' => 'remote@example.net'] + ); + $this->tokens->expects($this->once())->method('saveUserToken')->with($this->callback(fn(OAuthToken $t): bool => $t->refreshToken === 'ref')); + + $this->assertSame('remote@example.net', $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'local@example.org', 'c')); + } + + public function testConnectUserErrors(): void + { + $source = self::source(['oauth_flow' => 'authorization_code']); + $this->client->method('request')->willReturnOnConsecutiveCalls( + $this->throwException(new OAuthException('HTTP 400: invalid_grant')), + ['access_token' => 'acc'], + $this->throwException(new OAuthException('HTTP 401')), + ['access_token' => 'acc', 'id_token' => 'not.a.jwt.at.all'] + ); + $this->tokens->expects($this->never())->method('saveUserToken'); + + $this->assertUserError(['token_failed', 'token exchange failed for source 4: HTTP 400: invalid_grant'], fn() => $this->service()->connectUser($source, 'o', 'c')); + $this->assertUserError('no_identity', fn() => $this->service()->connectUser($source, 'o', 'c')); + $this->assertUserError('no_identity', fn() => $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code', 'oauth_userinfo_endpoint' => null]), 'o', 'c')); + } + + public function testConnectUserReportsStorageFailure(): void + { + $this->client->method('request')->willReturnOnConsecutiveCalls(['access_token' => 'acc'], ['email' => 'r@example.net']); + $this->tokens->method('saveUserToken')->willThrowException(new PDOException('duplicate')); + + $this->assertUserError(['token_failed', 'storing token failed for source 4: duplicate'], + fn() => $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'o', 'c')); + } + + private function assertUserError(string|array $msg, callable $call): void + { + try { + $call(); + $this->fail('UserError expected'); + } catch (UserError $e) { + $this->assertSame($msg, $e->msg()); + } + } +} diff --git a/tests/php/Imapsync/Service/SourceServiceTest.php b/tests/php/Imapsync/Service/SourceServiceTest.php new file mode 100644 index 000000000..54286ef5a --- /dev/null +++ b/tests/php/Imapsync/Service/SourceServiceTest.php @@ -0,0 +1,506 @@ +repo = $this->createMock(SourceRepository::class); + $this->tokens = $this->createMock(TokenRepository::class); + $this->client = $this->createMock(OAuthClient::class); + $this->client->method('isAllowed')->willReturn(true); + } + + /** SourceService with the real OAuthService (settings rules); only the HTTP client and the repositories are mocked */ + private function service(Context $context, ?OAuthClient $client = null): SourceService + { + return new SourceService($this->repo, $this->tokens, + new OAuthService($this->repo, $this->tokens, $client ?? $this->client, 'mail.example.org'), $context); + } + + private static function source(array $row = []): Source + { + return Source::fromRow($row + [ + 'id' => 7, + 'name' => 'src', + 'created_by' => 'owner@example.org', + 'scope' => 'user', + 'host1' => 'imap.example.org', + 'port1' => 993, + 'enc1' => 'SSL', + 'auth_type' => 'PLAIN', + 'active' => 1, + ]); + } + + private static function oauthSource(array $row = []): Source + { + return self::source($row + [ + 'created_by' => 'da', + 'auth_type' => 'XOAUTH2', + 'oauth_flow' => 'client_credentials', + 'oauth_token_endpoint' => 'https://login.example.org/token', + 'oauth_client_id' => 'client', + 'oauth_client_secret' => 'stored-secret', + 'oauth_scope' => 'https://outlook.office365.com/.default', + 'oauth_access_token' => 'access', + ]); + } + + private function expectUserError(string|array $msg, callable $call): void + { + try { + $call(); + $this->fail('UserError ' . json_encode($msg) . ' expected'); + } catch (UserError $e) { + $this->assertSame($msg, $e->msg()); + } + } + + public static function editMatrix(): array + { + $other = ['other@example.org']; + return [ + 'admin edits everything' => [FakeContext::admin(), 'x@example.org', true], + 'admin edits handed-over sources' => [FakeContext::admin(), '', true], + 'access_all_exception' => [new FakeContext('user', 'u@example.org', [], true), 'x@example.org', true], + 'user edits own' => [FakeContext::user('u@example.org'), 'u@example.org', true], + 'user not foreign' => [FakeContext::user('u@example.org'), 'x@example.org', false], + 'user not handed-over' => [FakeContext::user('u@example.org'), '', false], + 'domain admin edits own' => [FakeContext::domainAdmin(), 'da', true], + 'domain admin edits his mailboxes' => [FakeContext::domainAdmin($other), 'other@example.org', true], + 'domain admin not foreign mailboxes' => [FakeContext::domainAdmin($other), 'x@example.org', false], + 'domain admin not handed-over' => [FakeContext::domainAdmin(['']), '', false], + 'missing username is no creator' => [new FakeContext('user', null), '', false], + ]; + } + + #[DataProvider('editMatrix')] + public function testEditPermissionMatrix(Context $context, string $createdBy, bool $canEdit): void + { + $this->assertSame($canEdit, $this->service($context)->canEdit(self::source(['created_by' => $createdBy]))); + } + + public function testWithoutAclNothingIsVisibleExceptForAdmins(): void + { + $this->repo->expects($this->once())->method('findVisible')->willReturn(self::source()); + $this->repo->expects($this->once())->method('visibleIds')->willReturn([7]); + + $this->assertNull($this->service(FakeContext::user('u@example.org', []))->get(7)); + $this->assertSame([], $this->service(FakeContext::domainAdmin([], [], []))->visibleIds()); + $this->assertNotNull($this->service(FakeContext::admin([]))->get(7)); + $this->assertSame([7], $this->service(FakeContext::admin([]))->visibleIds()); + } + + public static function noEditors(): array + { + return [ + 'not logged in' => [new FakeContext('', null)], + 'system' => [Context::system()], + 'user without ACL' => [FakeContext::user('u@example.org', [])], + 'domain admin without ACL' => [FakeContext::domainAdmin([], [], ['syncjobs' => '0'])], + ]; + } + + #[DataProvider('noEditors')] + public function testCreateNeedsAnEditorWithAcl(Context $context): void + { + $this->repo->expects($this->never())->method('insert'); + + $this->expectUserError('access_denied', fn() => $this->service($context)->create(['name' => 'a', 'host' => 'h.example.org', 'port' => 993])); + } + + public function testUserSourceIsAlwaysPrivate(): void + { + $this->repo->expects($this->once())->method('insert')->with($this->callback(function (Source $s): bool { + return $s->scope === 'user' && $s->domains === [] && $s->users === [] && $s->createdBy === 'u@example.org' + && $s->host === 'imap.example.org' && $s->port === 993 && $s->enc === 'TLS' && $s->active === 1; + })); + + $source = $this->service(FakeContext::user('u@example.org'))->create([ + 'name' => ' Mine ', 'host' => 'IMAP.example.org', 'port' => '993', 'scope' => 'all', 'domains' => ['example.org'], + ]); + + $this->assertSame('Mine', $source->name); + } + + public function testUsersMayNotCreateXOAuth2Sources(): void + { + $this->repo->expects($this->never())->method('insert'); + + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user())->create($this->oauthInput())); + } + + public function testDomainAdminScopeRules(): void + { + $service = $this->service(FakeContext::domainAdmin(['m@example.org'], ['example.org'])); + $input = ['name' => 'a', 'host' => 'imap.example.org', 'port' => 993]; + + $this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'all'])); + $this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'everyone'])); + $this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'domain', 'domains' => ['foreign.org']])); + $this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'user', 'users' => ['x@foreign.org']])); + $this->expectUserError('imapsync_source_scope_empty', fn() => $service->create($input + ['scope' => 'domain', 'domains' => [' ', ['x']]])); + } + + public function testDomainScopeTargetsAreCleaned(): void + { + $this->repo->expects($this->once())->method('insert')->with($this->callback( + fn(Source $s): bool => $s->scope === 'domain' && $s->domains === ['example.org'] && $s->users === [] + )); + + $this->service(FakeContext::domainAdmin([], ['example.org']))->create([ + 'name' => 'a', 'host' => 'imap.example.org', 'port' => 993, + 'scope' => 'domain', 'domains' => [' Example.org', 'example.org', ''], 'users' => ['ignored@example.org'], + ]); + } + + public function testClientCredentialsSourcesAreNeverShared(): void + { + $this->repo->expects($this->once())->method('insert')->with($this->callback( + fn(Source $s): bool => $s->scope === 'user' && $s->domains === [] && $s->usesClientCredentials() + )); + + $this->service(FakeContext::admin())->create($this->oauthInput() + ['scope' => 'all']); + } + + public static function invalidBasics(): array + { + $valid = ['name' => 'a', 'description' => '', 'host' => 'imap.example.org', 'port' => 993, 'enc' => 'TLS', 'auth_type' => 'PLAIN']; + return [ + 'name missing' => [['name' => ''] + $valid, 'imapsync_source_name_invalid'], + 'name too long' => [['name' => str_repeat('n', 101)] + $valid, 'imapsync_source_name_invalid'], + 'name is array' => [['name' => ['x']] + $valid, 'imapsync_source_name_invalid'], + 'description too long' => [['description' => str_repeat('d', 256)] + $valid, 'imapsync_source_description_invalid'], + 'host invalid' => [['host' => 'imap_server.lan'] + $valid, 'imapsync_source_host_invalid'], + 'host too long' => [['host' => str_repeat('a.', 127) . 'org'] + $valid, 'imapsync_source_host_invalid'], + 'port 0' => [['port' => 0] + $valid, 'imapsync_source_port_invalid'], + 'port too high' => [['port' => 65536] + $valid, 'imapsync_source_port_invalid'], + 'enc unknown' => [['enc' => 'STARTTLS'] + $valid, 'imapsync_source_enc_invalid'], + 'auth unknown' => [['auth_type' => 'GSSAPI'] + $valid, 'imapsync_source_auth_invalid'], + 'name checked first' => [['name' => '', 'host' => '', 'port' => 0] + $valid, 'imapsync_source_name_invalid'], + ]; + } + + #[DataProvider('invalidBasics')] + public function testCreateValidatesBasics(array $input, string $msg): void + { + $this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create($input)); + } + + public function testIpAddressAndLegacyAliasesAreAccepted(): void + { + $this->repo->expects($this->once())->method('insert')->with($this->callback( + fn(Source $s): bool => $s->host === '192.0.2.10' && $s->port === 143 && $s->enc === 'PLAIN' + )); + + $this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']); + } + + public function testNameMustBeUniquePerCreator(): void + { + $this->repo->method('nameExists')->with('', 'admin', null)->willReturn(true); + $this->repo->expects($this->never())->method('insert'); + + $this->expectUserError(['object_exists', '<b>'], fn() => $this->service(FakeContext::admin())->create( + ['name' => '', 'host' => 'imap.example.org', 'port' => 993] + )); + } + + public static function invalidOAuth(): array + { + return [ + 'secret missing' => [['oauth_client_secret' => ''], 'imapsync_source_oauth_required'], + 'scope missing' => [['oauth_scope' => ''], 'imapsync_source_oauth_required'], + 'endpoint is array' => [['oauth_token_endpoint' => ['x']], 'imapsync_source_oauth_invalid'], + 'secret is array' => [['oauth_client_secret' => ['x']], 'imapsync_source_oauth_invalid'], + 'client id too long' => [['oauth_client_id' => str_repeat('c', 501)], 'imapsync_source_oauth_invalid'], + 'extra params list' => [['oauth_extra_params' => '[1,2]'], 'imapsync_source_oauth_extra_invalid'], + 'extra params nested' => [['oauth_extra_params' => ['a' => ['b' => 1]]], 'imapsync_source_oauth_extra_invalid'], + 'extra params no JSON' => [['oauth_extra_params' => '{nope'], 'imapsync_source_oauth_extra_invalid'], + 'extra params too long' => [['oauth_extra_params' => ['a' => str_repeat('x', 4000)]], 'imapsync_source_oauth_extra_invalid'], + ]; + } + + #[DataProvider('invalidOAuth')] + public function testOAuthAttributesAreValidated(array $override, string $msg): void + { + $this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create($override + $this->oauthInput())); + } + + public function testExtraParamsAreAcceptedAsArrayOrString(): void + { + $stored = []; + $this->repo->method('insert')->willReturnCallback(function (Source $s) use (&$stored): int { + $stored[] = $s->extraParams; + return 1; + }); + $service = $this->service(FakeContext::admin()); + + $service->create(['oauth_extra_params' => ['resource' => 'https://x/', 'n' => 1]] + $this->oauthInput()); + $service->create(['oauth_extra_params' => '{"resource":"https://x/"}'] + $this->oauthInput()); + $service->create(['oauth_extra_params' => ' '] + $this->oauthInput()); + + $this->assertSame(['{"resource":"https://x/","n":1}', '{"resource":"https://x/"}', null], $stored); + } + + public function testEndpointsMustPassThePolicyOfTheCreator(): void + { + // Sources of a domain admin ('da', no superadmin) get the strict address policy for every endpoint + $client = $this->createMock(OAuthClient::class); + $client->expects($this->exactly(3))->method('isAllowed') + ->willReturnCallback(fn(string $url, bool $strict): bool => $strict && $url !== 'https://internal/userinfo'); + $this->repo->method('isSuperAdmin')->with('da')->willReturn(false); + $service = $this->service(FakeContext::domainAdmin(), $client); + + $this->expectUserError('imapsync_source_oauth_endpoint_invalid', fn() => $service->create([ + 'oauth_flow' => 'authorization_code', + 'oauth_authorize_endpoint' => 'https://login.example.org/authorize', + 'oauth_userinfo_endpoint' => 'https://internal/userinfo', + ] + $this->oauthInput())); + } + + public function testUpdateNeedsVisibleEditableSource(): void + { + $this->repo->method('findVisible')->willReturnOnConsecutiveCalls(null, self::source(['created_by' => 'x@example.org'])); + $this->repo->expects($this->never())->method('update'); + $service = $this->service(FakeContext::user('u@example.org')); + + $this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b'])); + $this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b'])); + } + + public function testUsersMayNotSwitchToXOAuth2(): void + { + $this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org'])); + $this->repo->expects($this->never())->method('update'); + + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org'))->update(7, $this->oauthInput())); + } + + public function testUnchangedLegacyValuesAreNotValidatedAgain(): void + { + $this->repo->method('findVisible')->willReturn(self::source(['host1' => 'imap_server.lan', 'enc1' => null])); + $this->repo->expects($this->once())->method('update')->with( + $this->callback(fn(Source $s): bool => $s->name === 'renamed' && $s->host === 'imap_server.lan' && $s->enc === null), + false, + false + ); + + $this->service(FakeContext::admin())->update(7, ['name' => 'renamed', 'host' => 'IMAP_SERVER.lan']); + } + + public function testRenameMustStayUniqueForTheCreator(): void + { + $this->repo->method('findVisible')->willReturn(self::source()); + $this->repo->method('nameExists')->with('taken', 'owner@example.org', 7)->willReturn(true); + + $this->expectUserError(['object_exists', 'taken'], fn() => $this->service(FakeContext::admin())->update(7, ['name' => 'taken'])); + } + + public function testEmptySecretKeepsStoredOneAndTokensSurviveUnrelatedChanges(): void + { + $this->repo->method('findVisible')->willReturn(self::oauthSource()); + $this->repo->expects($this->once())->method('update')->with( + $this->callback(fn(Source $s): bool => $s->clientSecret === 'stored-secret' && $s->oauthScope === 'new-scope'), + true, + false + ); + + $this->service(FakeContext::domainAdmin())->update(7, ['oauth_client_secret' => '', 'oauth_scope' => 'new-scope']); + } + + public static function connectionChanges(): array + { + return [ + 'token endpoint' => [['oauth_token_endpoint' => 'https://other.example.org/token']], + 'client id' => [['oauth_client_id' => 'other']], + 'host' => [['host' => 'other.example.org']], + 'port' => [['port' => 143]], + 'encryption' => [['enc' => 'TLS']], + 'flow' => [['oauth_flow' => 'authorization_code', 'oauth_authorize_endpoint' => 'https://login.example.org/a']], + 'auth type' => [['auth_type' => 'PLAIN']], + ]; + } + + #[DataProvider('connectionChanges')] + public function testTokensAreResetWhenTheConnectionChanges(array $input): void + { + $this->repo->method('findVisible')->willReturn(self::oauthSource()); + $this->repo->expects($this->once())->method('update')->with($this->anything(), $this->anything(), true); + + $this->service(FakeContext::domainAdmin())->update(7, $input); + } + + public function testTargetsAreOnlyRewrittenWhenScopeIsSent(): void + { + $this->repo->method('findVisible')->willReturn(self::source(['scope' => 'domain'])); + $calls = []; + $this->repo->method('update')->willReturnCallback(function (Source $s, bool $targets) use (&$calls): void { + $calls[] = [$s->scope, $targets]; + }); + $service = $this->service(FakeContext::admin()); + + $service->update(7, ['name' => 'x']); + $service->update(7, ['scope' => 'all']); + + $this->assertSame([['domain', false], ['all', true]], $calls); + } + + public function testApiArrayNeverContainsSecrets(): void + { + $source = self::oauthSource(['oauth_extra_params' => '{"a":"b"}']); + + $editor = $this->service(FakeContext::admin())->toArray($source); + $viewer = $this->service(FakeContext::user('u@example.org'))->toArray($source); + + foreach ([$editor, $viewer] as $data) { + $this->assertArrayNotHasKey('oauth_client_secret', $data); + $this->assertArrayNotHasKey('oauth_access_token', $data); + $this->assertStringNotContainsString('stored-secret', json_encode($data)); + $this->assertTrue($data['has_client_secret']); + $this->assertTrue($data['has_access_token']); + $this->assertArrayNotHasKey('oauth_redirect_uri', $data); + } + $this->assertTrue($editor['can_edit']); + $this->assertSame('{"a":"b"}', $editor['oauth_extra_params']); + $this->assertFalse($viewer['can_edit']); + $this->assertNull($viewer['oauth_extra_params']); + } + + public function testApiArrayOfAuthorizationCodeSourceHasRedirectUri(): void + { + $data = $this->service(FakeContext::admin())->toArray(self::oauthSource(['oauth_flow' => 'authorization_code'])); + + $this->assertSame('https://mail.example.org/syncjob-oauth', $data['oauth_redirect_uri']); + $this->assertSame(['host' => 'imap.example.org', 'port' => 993, 'enc' => 'SSL'], + array_intersect_key($data, ['host' => 1, 'port' => 1, 'enc' => 1])); + } + + public function testDeleteRefusesSourcesInUse(): void + { + $this->repo->method('findVisible')->willReturn(self::source()); + $this->repo->method('isInUse')->willReturnOnConsecutiveCalls(true, false); + $this->repo->expects($this->once())->method('delete')->with(7); + $service = $this->service(FakeContext::admin()); + + $this->expectUserError(['imapsync_source_in_use', 'src'], fn() => $service->delete(7)); + $this->assertSame('src', $service->delete(7)->name); + } + + public function testRefreshTokenRules(): void + { + $this->repo->method('findVisible')->willReturnOnConsecutiveCalls( + self::oauthSource(['oauth_flow' => 'authorization_code']), + self::oauthSource(), + self::oauthSource() + ); + $this->client->method('request')->willReturnOnConsecutiveCalls( + $this->throwException(new OAuthException('HTTP 401: invalid_client')), + ['access_token' => 'new'] + ); + $service = $this->service(FakeContext::admin()); + + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::admin([]))->refreshToken(7)); + $this->expectUserError('imapsync_source_refresh_unsupported', fn() => $service->refreshToken(7)); + $this->expectUserError(['imapsync_source_token_refresh_failed', 'src (HTTP 401: invalid_client)'], fn() => $service->refreshToken(7)); + $this->assertSame('src', $service->refreshToken(7)->name); + } + + public function testConnectableSourceIsActiveAuthorizationCode(): void + { + $this->repo->method('findVisible')->willReturnOnConsecutiveCalls( + self::oauthSource(['oauth_flow' => 'authorization_code']), + self::oauthSource(['oauth_flow' => 'authorization_code', 'active' => 0]), + self::oauthSource() + ); + $service = $this->service(FakeContext::user()); + + $this->assertNotNull($service->findConnectable(7)); + $this->assertNull($service->findConnectable(7)); + $this->assertNull($service->findConnectable(7)); + } + + public function testAccountRenameReleasesLeftoversAndMovesEverything(): void + { + $this->repo->method('listCreatedBy')->with('new@example.org')->willReturn([ + self::source(['id' => 1, 'name' => 'unused']), + self::source(['id' => 2, 'name' => 'used']), + ]); + $this->repo->method('isInUse')->willReturnMap([[1, false], [2, true]]); + $this->repo->method('nameExists')->with('used', '')->willReturn(true); + $this->repo->expects($this->once())->method('deleteWithChildren')->with(1); + $this->repo->expects($this->once())->method('handOver')->with(2, 'used (#2)'); + $this->repo->expects($this->once())->method('renameAccount')->with('old@example.org', 'new@example.org'); + $this->tokens->expects($this->once())->method('renameOwner')->with('old@example.org', 'new@example.org'); + + $this->service(Context::system())->onAccountRenamed('old@example.org', 'new@example.org'); + } + + public function testAccountRenameToItselfDoesNothing(): void + { + $this->repo->expects($this->never())->method('listCreatedBy'); + $this->tokens->expects($this->never())->method('renameOwner'); + + $this->service(Context::system())->onAccountRenamed('a@example.org', 'a@example.org'); + $this->service(Context::system())->onAccountDeleted(''); + } + + public function testAccountDeleteRemovesTokensAndTargets(): void + { + $this->repo->method('listCreatedBy')->with('old@example.org')->willReturn([self::source(['id' => 3, 'name' => str_repeat('n', 100)])]); + $this->repo->method('isInUse')->willReturn(true); + $this->repo->method('nameExists')->willReturn(true); + $this->repo->expects($this->once())->method('handOver')->with(3, str_repeat('n', 80) . ' (#3)'); + $this->tokens->expects($this->once())->method('deleteByOwner')->with('old@example.org'); + $this->repo->expects($this->once())->method('deleteUserTarget')->with('old@example.org'); + + $this->service(Context::system())->onAccountDeleted('old@example.org'); + } + + public function testAccountCascadeLetsDatabaseErrorsThrough(): void + { + $this->repo->method('listCreatedBy')->willThrowException(new PDOException('gone')); + + $this->expectException(PDOException::class); + $this->service(Context::system())->onAccountDeleted('old@example.org'); + } + + private function oauthInput(): array + { + return [ + 'name' => 'oauth', + 'host' => 'outlook.office365.com', + 'port' => 993, + 'enc' => 'SSL', + 'auth_type' => 'XOAUTH2', + 'oauth_flow' => 'client_credentials', + 'oauth_token_endpoint' => 'https://login.example.org/token', + 'oauth_client_id' => 'client', + 'oauth_client_secret' => 'secret', + 'oauth_scope' => 'scope', + ]; + } +} diff --git a/tests/php/Support/FakeContext.php b/tests/php/Support/FakeContext.php new file mode 100644 index 000000000..8a868a63a --- /dev/null +++ b/tests/php/Support/FakeContext.php @@ -0,0 +1,56 @@ + '1'], bool $accessAll = false, + array $mailboxes = [], array $domains = []) + { + parent::__construct($role, $username, $acl, $accessAll); + $this->mailboxes = $mailboxes; + $this->domains = $domains; + } + + public static function admin(array $acl = ['syncjobs' => '1']): self + { + return new self('admin', 'admin', $acl, false, ['*'], ['*']); + } + + public static function domainAdmin(array $mailboxes = [], array $domains = [], array $acl = ['syncjobs' => '1']): self + { + return new self('domainadmin', 'da', $acl, false, $mailboxes, $domains); + } + + public static function user(string $username = 'user@example.org', array $acl = ['syncjobs' => '1']): self + { + return new self('user', $username, $acl); + } + + public function canAccessMailbox(string $mailbox): bool + { + if ($this->accessAll) { + return true; + } + if (!in_array($this->role, ['admin', 'domainadmin', 'user'], true) || $this->username === null) { + return false; + } + return $mailbox === $this->username || in_array($mailbox, $this->mailboxes, true) || in_array('*', $this->mailboxes, true); + } + + public function canAccessDomain(string $domain): bool + { + return $this->accessAll || in_array($domain, $this->domains, true) || in_array('*', $this->domains, true); + } +} diff --git a/tests/php/Support/FakeOAuthClient.php b/tests/php/Support/FakeOAuthClient.php new file mode 100644 index 000000000..5c0e16a05 --- /dev/null +++ b/tests/php/Support/FakeOAuthClient.php @@ -0,0 +1,35 @@ + host => addresses */ + public array $addresses = []; + /** @var array{0: string|false, 1: int} */ + public array $response = ['{}', 200]; + /** @var array[] curl options of every send() */ + public array $sent = []; + /** @var string[] hosts passed to resolve() */ + public array $resolved = []; + + protected function resolve(string $host): array + { + $this->resolved[] = $host; + return $this->addresses[$host] ?? []; + } + + protected function send(array $options): array + { + $this->sent[] = $options; + return $this->response; + } +} diff --git a/tests/php/Support/LegacyAclCalls.php b/tests/php/Support/LegacyAclCalls.php new file mode 100644 index 000000000..bdee3aef0 --- /dev/null +++ b/tests/php/Support/LegacyAclCalls.php @@ -0,0 +1,13 @@ +=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.29" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tests/php/phpunit.xml.dist b/tests/php/phpunit.xml.dist new file mode 100644 index 000000000..27aa18324 --- /dev/null +++ b/tests/php/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + . + vendor + + + + + ../../data/web/inc/lib/mailcow + + + From 7c62a57910bf37253409b907d749e390c12dd35f Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:45:05 +0200 Subject: [PATCH 3/5] [Web][Dovecot] Rework imapsync: sync sources, OAuth2 (XOAUTH2), worker pool, Python runner --- data/Dockerfiles/dovecot/Dockerfile | 4 +- data/Dockerfiles/dovecot/docker-entrypoint.sh | 2 +- data/Dockerfiles/dovecot/imapsync_runner.pl | 196 --- data/Dockerfiles/dovecot/imapsync_runner.py | 274 ++++ .../phpfpm/crons/imapsync-oauth-refresh.php | 73 ++ data/web/admin/mailbox.php | 2 + data/web/admin/system.php | 1 + data/web/api/openapi.yaml | 1162 ++++++++++++++--- data/web/domainadmin/mailbox.php | 2 + data/web/edit.php | 10 +- data/web/inc/ajax/syncjob_logs.php | 2 +- data/web/inc/footer.inc.php | 5 +- data/web/inc/functions.admin.inc.php | 6 +- data/web/inc/functions.domain_admin.inc.php | 7 +- data/web/inc/functions.mailbox.inc.php | 493 +------ data/web/inc/init_db.inc.php | 312 ++++- data/web/inc/vars.inc.php | 221 ++-- data/web/js/build/013-mailcow.js | 10 +- data/web/js/site/imapsync.js | 707 ++++++++++ data/web/js/site/mailbox.js | 59 +- data/web/js/site/user.js | 62 +- data/web/json_api.php | 104 +- data/web/lang/lang.en-gb.json | 209 ++- data/web/syncjob-oauth.php | 103 ++ data/web/templates/admin.twig | 2 + .../templates/admin/tab-config-syncjob.twig | 35 + data/web/templates/edit.twig | 1 + data/web/templates/edit/imapsync_source.twig | 233 ++++ data/web/templates/edit/syncjob.twig | 133 +- data/web/templates/mailbox.twig | 3 +- data/web/templates/mailbox/tab-syncjobs.twig | 43 +- .../web/templates/modals/imapsync_source.twig | 177 +++ data/web/templates/modals/mailbox.twig | 126 +- data/web/templates/modals/user.twig | 120 +- data/web/templates/user.twig | 2 +- data/web/templates/user/Syncjobs.twig | 29 +- .../templates/user_domainadmin_common.twig | 1 + data/web/user.php | 2 + docker-compose.yml | 7 +- 39 files changed, 3582 insertions(+), 1358 deletions(-) delete mode 100644 data/Dockerfiles/dovecot/imapsync_runner.pl create mode 100755 data/Dockerfiles/dovecot/imapsync_runner.py create mode 100644 data/conf/phpfpm/crons/imapsync-oauth-refresh.php create mode 100644 data/web/js/site/imapsync.js create mode 100644 data/web/syncjob-oauth.php create mode 100644 data/web/templates/admin/tab-config-syncjob.twig create mode 100644 data/web/templates/edit/imapsync_source.twig create mode 100644 data/web/templates/modals/imapsync_source.twig diff --git a/data/Dockerfiles/dovecot/Dockerfile b/data/Dockerfiles/dovecot/Dockerfile index f1152c8a1..bd57f1adf 100644 --- a/data/Dockerfiles/dovecot/Dockerfile +++ b/data/Dockerfiles/dovecot/Dockerfile @@ -68,7 +68,6 @@ RUN addgroup -g 5000 vmail \ perl-package-stash-xs \ perl-par-packer \ perl-parse-recdescent \ - perl-lockfile-simple \ libproc2 \ perl-readonly \ perl-regexp-common \ @@ -84,7 +83,6 @@ RUN addgroup -g 5000 vmail \ perl-test-warn \ perl-try-tiny \ perl-unicode-string \ - perl-proc-processtable \ perl-app-cpanminus \ procps \ python3 \ @@ -120,7 +118,7 @@ COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf COPY imapsync /usr/local/bin/imapsync -COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl +COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh index e58abbb6a..d5175e96d 100755 --- a/data/Dockerfiles/dovecot/docker-entrypoint.sh +++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh @@ -279,7 +279,7 @@ chmod g+rw /dev/console chown root:tty /dev/console chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \ /usr/lib/dovecot/sieve/rspamd-pipe-spam \ - /usr/local/bin/imapsync_runner.pl \ + /usr/local/bin/imapsync_runner.py \ /usr/local/bin/imapsync \ /usr/local/bin/trim_logs.sh \ /usr/local/bin/sa-rules.sh \ diff --git a/data/Dockerfiles/dovecot/imapsync_runner.pl b/data/Dockerfiles/dovecot/imapsync_runner.pl deleted file mode 100644 index 1030603ce..000000000 --- a/data/Dockerfiles/dovecot/imapsync_runner.pl +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/perl - -use DBI; -use LockFile::Simple qw(lock trylock unlock); -use Proc::ProcessTable; -use Data::Dumper qw(Dumper); -use IPC::Run 'run'; -use File::Temp; -use Try::Tiny; -use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT); - -sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s }; -my $t = Proc::ProcessTable->new; -my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table}; -if ($imapsync_running ge 1) -{ - print "imapsync is active, exiting..."; - exit; -} - -sub qqw($) { - my @params = (); - my @values = split(/(?=--)/, $_[0]); - foreach my $val (@values) { - my @tmpparam = split(/ /, $val, 2); - foreach my $tmpval (@tmpparam) { - if ($tmpval ne '') { - push @params, $tmpval; - } - } - } - foreach my $val (@params) { - $val=trim($val); - } - return @params; -} - -$run_dir="/tmp"; -$dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock'; -$lock_file = $run_dir . "/imapsync_busy"; -$lockmgr = LockFile::Simple->make(-autoclean => 1, -max => 1); -$lockmgr->lock($lock_file) || die "can't lock ${lock_file}"; -$dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, { - mysql_auto_reconnect => 1, - mysql_enable_utf8mb4 => 1 -}); -$dbh->do("UPDATE imapsync SET is_running = 0"); - -sub sig_handler { - # Send die to force exception in "run" - die "sig_handler received signal, preparing to exit...\n"; -}; - -open my $file, '<', "/etc/sogo/sieve.creds"; -my $creds = <$file>; -close $file; -my ($master_user, $master_pass) = split /:/, $creds; -my $sth = $dbh->prepare("SELECT id, - user1, - user2, - host1, - authmech1, - password1, - exclude, - port1, - enc1, - delete2duplicates, - maxage, - subfolder2, - delete1, - delete2, - automap, - skipcrossduplicates, - maxbytespersecond, - custom_params, - subscribeall, - timeout1, - timeout2, - dry - FROM imapsync - WHERE active = 1 - AND is_running = 0 - AND ( - UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(last_run) > mins_interval * 60 - OR - last_run IS NULL) - ORDER BY last_run"); - -$sth->execute(); -my $row; - -while ($row = $sth->fetchrow_arrayref()) { - - $id = @$row[0]; - $user1 = @$row[1]; - $user2 = @$row[2]; - $host1 = @$row[3]; - $authmech1 = @$row[4]; - $password1 = @$row[5]; - $exclude = @$row[6]; - $port1 = @$row[7]; - $enc1 = @$row[8]; - $delete2duplicates = @$row[9]; - $maxage = @$row[10]; - $subfolder2 = @$row[11]; - $delete1 = @$row[12]; - $delete2 = @$row[13]; - $automap = @$row[14]; - $skipcrossduplicates = @$row[15]; - $maxbytespersecond = @$row[16]; - $custom_params = @$row[17]; - $subscribeall = @$row[18]; - $timeout1 = @$row[19]; - $timeout2 = @$row[20]; - $dry = @$row[21]; - - if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; } - - my $template = $run_dir . '/imapsync.XXXXXXX'; - my $passfile1 = File::Temp->new(TEMPLATE => $template); - my $passfile2 = File::Temp->new(TEMPLATE => $template); - - binmode( $passfile1, ":utf8" ); - - print $passfile1 "$password1\n"; - print $passfile2 trim($master_pass) . "\n"; - - my @custom_params_a = qqw($custom_params); - my $custom_params_ref = \@custom_params_a; - - my $generated_cmds = [ "/usr/local/bin/imapsync", - "--tmpdir", "/tmp", - "--nofoldersizes", - "--addheader", - ($timeout1 le "0" ? () : ('--timeout1', $timeout1)), - ($timeout2 le "0" ? () : ('--timeout2', $timeout2)), - ($exclude eq "" ? () : ("--exclude", $exclude)), - ($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)), - ($maxage eq "0" ? () : ('--maxage', $maxage)), - ($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)), - ($delete2duplicates ne "1" ? () : ('--delete2duplicates')), - ($subscribeall ne "1" ? () : ('--subscribeall')), - ($delete1 ne "1" ? () : ('--delete')), - ($delete2 ne "1" ? () : ('--delete2')), - ($automap ne "1" ? () : ('--automap')), - ($skipcrossduplicates ne "1" ? () : ('--skipcrossduplicates')), - (!defined($enc1) ? () : ($enc1)), - "--host1", $host1, - "--user1", $user1, - "--passfile1", $passfile1->filename, - "--port1", $port1, - "--host2", "localhost", - "--user2", $user2 . '*' . trim($master_user), - "--passfile2", $passfile2->filename, - ($dry eq "1" ? ('--dry') : ()), - '--no-modulesversion', - '--noreleasecheck']; - - try { - $is_running = $dbh->prepare("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = ?"); - $is_running->bind_param( 1, ${id} ); - $is_running->execute(); - - run [@$generated_cmds, @$custom_params_ref], '&>', \my $stdout; - - # check exit code and status - ($exit_code, $exit_status) = ($stdout =~ m/Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)/); - - $success = 0; - if (defined $exit_code && $exit_code == 0) { - $success = 1; - } - - $update = $dbh->prepare("UPDATE imapsync SET returned_text = ?, success = ?, exit_status = ? WHERE id = ?"); - $update->bind_param( 1, ${stdout} ); - $update->bind_param( 2, ${success} ); - $update->bind_param( 3, ${exit_status} ); - $update->bind_param( 4, ${id} ); - $update->execute(); - } catch { - $update = $dbh->prepare("UPDATE imapsync SET returned_text = 'Could not start or finish imapsync', success = 0 WHERE id = ?"); - $update->bind_param( 1, ${id} ); - $update->execute(); - } finally { - $update = $dbh->prepare("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = ?"); - $update->bind_param( 1, ${id} ); - $update->execute(); - }; - - -} - -$sth->finish(); -$dbh->disconnect(); - -$lockmgr->unlock($lock_file); diff --git a/data/Dockerfiles/dovecot/imapsync_runner.py b/data/Dockerfiles/dovecot/imapsync_runner.py new file mode 100755 index 000000000..b74e1d845 --- /dev/null +++ b/data/Dockerfiles/dovecot/imapsync_runner.py @@ -0,0 +1,274 @@ +#!/usr/bin/python3 + +import fcntl +import json +import os +import re +import signal +import subprocess +import sys +import tempfile +import time +import MySQLdb +import MySQLdb.cursors + +run_dir = '/tmp' +# Max size of imapsync output stored in returned_text (the tail holds the summary) +max_output = 16 * 1024 * 1024 +lock_file = run_dir + '/imapsync_busy.lock' + +# Next due job: higher prio first, never-run jobs first, then oldest run +next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage, + i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params, + i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id, + s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow, s.oauth_access_token, s.oauth_token_expires + FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id + WHERE i.active = 1 + AND s.active = 1 + AND i.is_running = 0 + AND (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60 OR i.last_run IS NULL) + ORDER BY i.prio DESC, i.last_run IS NOT NULL, i.last_run ASC, i.id ASC + LIMIT 1""" +# Atomic claim, a job is only started when this flips is_running 0 -> 1 +claim_sql = """UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL + WHERE id = %s AND is_running = 0 AND active = 1""" +# Token of authorization_code sources: source + local mailbox (user2) + remote user (user1) +token_sql = """SELECT access_token, token_expires FROM imapsync_source_oauth_token + WHERE source_id = %s AND owner = %s AND username = %s""" + +conn = None +# Popen => job of running workers +running = {} +stopping = False + +def num(value): + try: + return int(value) + except (TypeError, ValueError): + return 0 + +def unlink(files): + for path in files: + try: + os.unlink(path) + except OSError: + pass + +def imapsync_running(): + for pid in filter(str.isdigit, os.listdir('/proc')): + try: + with open('/proc/' + pid + '/cmdline', 'rb') as f: + cmdline = f.read().replace(b'\0', b' ') + except OSError: + continue + if re.search(rb'imapsync\s', cmdline, re.I): + return True + return False + +def connect(): + return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'], + passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True) + +# Reconnect when the server dropped the connection (syncs can outlast wait_timeout) +def query(sql, args=None): + global conn + try: + conn.ping() + except MySQLdb.Error: + conn = connect() + cur = conn.cursor(MySQLdb.cursors.DictCursor) + cur.execute(sql, args) + return cur + +# Store a result and release the job +def finish_job(job_id, text, success, exit_status): + try: + query("UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s, last_run = NOW(), is_running = 0 WHERE id = %s", + (text, success, exit_status, job_id)) + except MySQLdb.Error: + query("UPDATE imapsync SET success = 0, last_run = NOW(), is_running = 0 WHERE id = %s", (job_id,)) + +# Max concurrent imapsync processes and global per-process bandwidth cap in bytes/s (0 = off), +# re-read before every claim round so changes apply to long runs +def read_settings(): + settings = {r['name']: r['value'] for r in query("SELECT name, value FROM imapsync_settings")} + return max(num(settings.get('max_parallel')), 1), max(num(settings.get('max_bytes_per_second')), 0) + +# Build temp files and argv for a claimed job. Returns None when the job was finished without running imapsync. +def prepare_job(j, global_bw, files): + def tmpfile(content): + fd, path = tempfile.mkstemp(prefix='imapsync.', dir=run_dir) + files.append(path) + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(content) + return path + + auth_type = j['auth_type'] or 'PLAIN' + if auth_type == 'XOAUTH2': + # authorization_code: token of this local mailbox + remote user, client_credentials: shared source token + token, expires = j['oauth_access_token'], j['oauth_token_expires'] + if j['oauth_flow'] == 'authorization_code': + row = query(token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {} + token, expires = row.get('access_token'), row.get('token_expires') + # Skip sync if the cached token is missing or about to expire (<2 min) + if not token or expires is None or expires - time.time() < 120: + finish_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.", + 0, 'OAUTH_TOKEN_NOT_READY') + return None + auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', tmpfile(token + '\n')] + else: + auth_args = ['--passfile1', tmpfile(j['password1'] + '\n')] + # imapsync auto-picks PLAIN, only pass --authmech1 for LOGIN/CRAM-MD5 + if auth_type != 'PLAIN': + auth_args += ['--authmech1', auth_type] + + # Custom params: JSON array of {o,v}, validated by the backend on write. One argv token per pair, + # "--opt" or "--opt=value" (value glued to its option, no shell) + custom_params = [] + try: + pairs = json.loads(j['custom_params'] or '[]') + except ValueError: + pairs = [] + for p in pairs if isinstance(pairs, list) else []: + if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']): + continue + value = '' if p.get('v') is None else str(p['v']) + custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value) + + # Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit + eff_bw = num(j['maxbytespersecond']) + if global_bw > 0 and (eff_bw <= 0 or eff_bw > global_bw): + eff_bw = global_bw + + cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--nofoldersizes', '--addheader'] + if num(j['timeout1']) > 0: + cmd += ['--timeout1', str(j['timeout1'])] + if num(j['timeout2']) > 0: + cmd += ['--timeout2', str(j['timeout2'])] + if j['exclude']: + cmd += ['--exclude', j['exclude']] + if j['subfolder2']: + cmd += ['--subfolder2', j['subfolder2']] + if num(j['maxage']) != 0: + cmd += ['--maxage', str(j['maxage'])] + if eff_bw > 0: + cmd += ['--maxbytespersecond', str(eff_bw)] + for col, opt in (('delete2duplicates', '--delete2duplicates'), ('subscribeall', '--subscribeall'), + ('delete1', '--delete'), ('delete2', '--delete2'), ('automap', '--automap'), + ('skipcrossduplicates', '--skipcrossduplicates')): + if j[col] == 1: + cmd.append(opt) + enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1']) + if enc1: + cmd.append(enc1) + cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])] + cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')] + if j['dry'] == 1: + cmd.append('--dry') + cmd += ['--no-modulesversion', '--noreleasecheck'] + custom_params + + return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd} + +# Worker: imapsync with output to a file, no inherited fds (DB socket, lock) +def start_job(job): + with open(job['outfile'], 'wb') as out: + return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT) + +# Store the result of a finished worker and remove its temp files +def reap_job(job): + stdout = b'' + try: + with open(job['outfile'], 'rb') as f: + size = os.fstat(f.fileno()).st_size + if size > max_output: + f.seek(size - max_output) + stdout = b'[output truncated, last %d bytes]\n' % max_output + stdout += f.read() + except OSError: + pass + unlink(job['files']) + text = stdout.decode('utf-8', 'replace') + # Last match: the echoed command line at the top may contain user controlled values + m = re.findall(r'Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)', text) + exit_code, exit_status = m[-1] if m else (None, None) + if exit_code is None: + text = 'Could not start or finish imapsync\n' + text + finish_job(job['id'], text, 1 if exit_code is not None and int(exit_code) == 0 else 0, exit_status) + +# Stop claiming new jobs, pass the signal on to the running imapsync processes +def stop(signum, frame): + global stopping + stopping = True + for proc in list(running): + proc.send_signal(signal.SIGTERM) + +# Released by the kernel when the process ends, a crashed run leaves no stale lock +lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600) +try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) +except OSError: + sys.exit("can't lock " + lock_file) + +if imapsync_running(): + print('imapsync is active, exiting...') + sys.exit(0) + +for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT): + signal.signal(sig, stop) + +conn = connect() +# Stale flags from crashed runs, safe only here: under the lock and with no imapsync running +query("UPDATE imapsync SET is_running = 0") + +with open('/etc/sogo/sieve.creds') as f: + master_user, master_pass = [s.strip() for s in f.readline().split(':', 1)] + +# Worker pool: keep up to max_parallel workers busy, claim the next due job whenever a slot is free +# (after every worker exit and at least every 10 seconds while workers run) +try: + while True: + for proc, job in list(running.items()): + if proc.poll() is not None: + del running[proc] + reap_job(job) + + max_parallel, global_bw = read_settings() + attempts = 0 + while not stopping and len(running) < max_parallel and attempts < 100: + attempts += 1 + j = query(next_sql).fetchone() + if not j: + break + if query(claim_sql, (j['id'],)).rowcount != 1: + continue + + files = [] + try: + job = prepare_job(j, global_bw, files) + except Exception as e: + print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr) + finish_job(j['id'], 'Could not start or finish imapsync', 0, None) + job = None + if not job: + unlink(files) + continue + try: + running[start_job(job)] = job + except OSError as e: + # Release the claim, the job is picked up again later + print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr) + unlink(files) + query("UPDATE imapsync SET is_running = 0 WHERE id = %s", (j['id'],)) + break + + if not running: + break + # A worker exit ends the wait early + for _ in range(50): + if any(proc.poll() is not None for proc in running): + break + time.sleep(0.2) +finally: + # Never leave password/token files behind + for job in running.values(): + unlink(job['files']) diff --git a/data/conf/phpfpm/crons/imapsync-oauth-refresh.php b/data/conf/phpfpm/crons/imapsync-oauth-refresh.php new file mode 100644 index 000000000..a90088963 --- /dev/null +++ b/data/conf/phpfpm/crons/imapsync-oauth-refresh.php @@ -0,0 +1,73 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} catch (PDOException $e) { + fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +$redis = new Redis(); +try { + if (!empty(getenv('REDIS_SLAVEOF_IP'))) { + $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); + } else { + $redis->connect('redis-mailcow', 6379); + } + $redis->auth(getenv("REDISPASS")); +} catch (Exception $e) { + fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +function logMsg($priority, $message, $task = "imapsync OAuth refresh") { + global $redis; + $redis->lPush('CRON_LOG', json_encode(array( + "time" => time(), + "priority" => $priority, + "task" => $task, + "message" => $message, + ))); +} + +require_once __DIR__ . '/../web/inc/functions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; +require_once __DIR__ . '/../web/inc/sessions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; + +// File-lock to prevent concurrent runs +$lock_file = '/tmp/imapsync-oauth-refresh.lock'; +if (file_exists($lock_file)) { + $pid = (int)trim(@file_get_contents($lock_file)); + if ($pid > 0 && posix_kill($pid, 0)) { + logMsg("info", "Previous refresh still running (pid=$pid), exiting"); + exit(0); + } + @unlink($lock_file); +} +file_put_contents($lock_file, getmypid()); + +try { + // client_credentials source tokens, unused per-user tokens (1 day grace), authorization_code per-user tokens; + // failing tokens are retried 15 minutes after their last attempt (C2), errors are sanitized + $imapsync = \Mailcow\Imapsync\ImapsyncModule::create($pdo, \Mailcow\Core\Context::system(), [ + 'hostname' => (string)getenv('MAILCOW_HOSTNAME'), + ]); + foreach ($imapsync->oauth->refreshDue() as $e) { + logMsg($e['priority'], $e['message']); + } +} finally { + @unlink($lock_file); +} diff --git a/data/web/admin/mailbox.php b/data/web/admin/mailbox.php index 7b07d6fa9..a79c68c1a 100644 --- a/data/web/admin/mailbox.php +++ b/data/web/admin/mailbox.php @@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; +$js_minifier->add('/web/js/site/imapsync.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -42,6 +43,7 @@ $template_data = [ 'lang_rl' => json_encode($lang['ratelimit']), 'lang_edit' => json_encode($lang['edit']), 'lang_datatables' => json_encode($lang['datatables']), + 'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(), ]; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; diff --git a/data/web/admin/system.php b/data/web/admin/system.php index b5e00808f..c74f8c6a6 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -118,6 +118,7 @@ $template_data = [ 'ui_themes' => customize('get', 'ui_themes'), 'ui_theme' => customize('get', 'ui_theme'), 'password_complexity' => password_complexity('get'), + 'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(), 'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'], 'cors_settings' => $cors_settings, 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index c86561903..0044ac1e8 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1465,31 +1465,15 @@ paths: response: value: - log: - - mailbox - - add - syncjob - - active: "1" - automap: "1" - custom_params: "" - delete1: "0" - delete2: "0" - delete2duplicates: "1" - enc1: SSL - exclude: (?i)spam|(?i)junk - host1: imap.server.tld - maxage: "0" - maxbytespersecond: "0" - mins_interval: "20" - password1: supersecret - port1: 993 - skipcrossduplicates: "0" - subfolder2: External - subscribeall: "1" - timeout1: "600" - timeout2: "600" + - add + - job + - username: mailbox@domain.tld + source_id: "3" user1: username - username: mailbox@domain.tld - - null + password1: "*" + mins_interval: "20" + active: "1" msg: - mailbox_modified - mailbox@domain.tld @@ -1497,7 +1481,7 @@ paths: schema: properties: log: - description: contains request object + description: contains request object (secrets are masked as "*") items: {} type: array msg: @@ -1515,8 +1499,10 @@ paths: tags: - Sync jobs description: >- - You can create new sync job using this action. It takes a JSON object - containing a domain informations. + Create a sync job for a local mailbox. Connection data (host, port, + encryption, authentication) comes from the sync source referenced by + `source_id`. Requires the `syncjobs` ACL (user ACL for mailbox users, + domain admin ACL for domain admins). operationId: Create sync job summary: Create sync job requestBody: @@ -1525,11 +1511,9 @@ paths: schema: example: username: lisa@mailcow.tld - host1: mail.mailcow.tld - port1: "143" - user1: demo@mailcow.tld + source_id: "3" + user1: demo@remote.tld password1: supersecretpw - enc1: TLS mins_interval: "20" subfolder2: "/SyncIntoSubfolder" maxage: "0" @@ -1537,44 +1521,47 @@ paths: timeout1: "600" timeout2: "600" exclude: "(?i)spam|(?i)junk" - custom_params: "--dry" + custom_params: + - {o: folder, v: INBOX} + - {o: syncinternaldates, v: ""} delete2duplicates: "1" - delete1: "1" + delete1: "0" delete2: "0" automap: "1" skipcrossduplicates: "0" - subscribeall: "0" + subscribeall: "1" + dry: "0" active: "1" properties: - parameters: - description: your local mailcow mailbox - type: string - host1: - description: the smtp server where mails should be synced from - type: string - port1: - description: the smtp port of the target mail server + username: + description: local mailcow mailbox (destination, user2); users always create jobs for their own mailbox type: string + source_id: + description: >- + id of a sync source visible to the caller (`GET /api/v1/get/syncjob_source/all`). + type: integer user1: - description: the username of the mailbox + description: >- + remote username at the source. For XOAUTH2 authorization_code sources + a token for (source_id, local mailbox, user1) must exist, i.e. the + mailbox owner must have signed in via `/syncjob-oauth` first + (`imapsync_source_oauth_connect_required` otherwise). type: string - password: - description: the password of the mailbox + password1: + description: >- + remote password, required unless the source uses XOAUTH2 (ignored then) type: string - enc1: - description: the encryption method used to connect to the mailserver - type: string - mins_internal: - description: the interval in which messages should be syned + mins_interval: + description: sync interval in minutes (1-43800) type: number subfolder2: description: sync into subfolder on destination (empty = do not use subfolder) type: string maxage: - description: only sync messages up to this age in days + description: only sync messages up to this age in days (0 = ignore age) type: number maxbytespersecond: - description: max speed transfer limit for the sync + description: per-job bandwidth limit in bytes/s (0 = unlimited, capped by the global limit) type: number timeout1: description: timeout for connection to remote host @@ -1586,8 +1573,22 @@ paths: description: exclude objects (regex) type: string custom_params: - description: custom parameters - type: string + description: >- + Allowlisted imapsync options as a list of option/value pairs (must be + an array). + `o` is the option name without leading dashes (see `GET + /api/v1/get/syncjob_options`), `v` the value. The allowlist decides + per option: `true` = a non-empty value is required, `false` = flag + without value (`v` must be empty), string = regular expression the + whole value must match. Values are passed to imapsync as a single argument. + type: array + items: + type: object + properties: + o: + type: string + v: + type: string delete2duplicates: description: delete duplicates on destination (--delete2duplicates) type: boolean @@ -1606,9 +1607,215 @@ paths: subscribeall: description: subscribe all folders (--subscribeall) type: boolean + dry: + description: dry run, nothing is changed (--dry) + type: boolean active: description: enables or disables the sync job type: boolean + host1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + port1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + enc1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + authmech1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + type: object + /api/v1/add/syncjob_source: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - add + - source + - name: Example import + host: imap.example.org + port: "993" + enc: SSL + auth_type: PLAIN + scope: all + msg: + - imapsync_source_added + - Example import + type: success + schema: + properties: + log: + description: contains request object (secrets are masked as "*") + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Create a reusable IMAP sync source. A sync job binds to a source by id; + the source carries host/port/encryption and either static credentials + (PLAIN/LOGIN/CRAM-MD5 — the password is supplied per job) or an OAuth2 + configuration (XOAUTH2). Requires the `syncjobs` ACL. Validation errors + are returned as translated `danger` messages, nothing is written then. + operationId: Create sync job source + summary: Create sync job source + requestBody: + content: + application/json: + schema: + example: + name: Example import + description: "" + host: imap.example.org + port: "993" + enc: SSL + auth_type: PLAIN + active: "1" + scope: domain + domains: + - example.org + properties: + name: + description: >- + source name, max. 100 characters, unique per creator + type: string + description: + description: >- + free text shown in the UI, max. 255 characters + type: string + host: + description: >- + remote IMAP host (hostname or IP, max. 253 characters). + `host1` is accepted as an alias. + type: string + port: + description: >- + remote IMAP port (1-65535). `port1` is accepted as an alias. + type: integer + enc: + description: >- + TLS (STARTTLS) / SSL / PLAIN. `enc1` is accepted as an alias. + type: string + enum: + - TLS + - SSL + - PLAIN + auth_type: + description: >- + authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job + password, XOAUTH2 uses OAuth2 tokens. XOAUTH2 sources can only be created + and edited by admins and domain admins (`access_denied` for users); users + can use XOAUTH2 sources visible to them in their sync jobs. + type: string + enum: + - PLAIN + - LOGIN + - CRAM-MD5 + - XOAUTH2 + oauth_flow: + description: >- + XOAUTH2 grant type (default client_credentials). client_credentials + sources are always private to their creator (scope is forced to `user` + without targets). + type: string + enum: + - client_credentials + - authorization_code + oauth_authorize_endpoint: + description: >- + authorize URL, required for authorization_code. Must be an https:// URL + without credentials and with a valid hostname + type: string + oauth_token_endpoint: + description: >- + token URL, required for XOAUTH2. Must be an https:// URL without credentials and with a valid hostname + type: string + oauth_userinfo_endpoint: + description: >- + optional identity endpoint for authorization_code (used when the token + response has no usable id_token). Must be an https:// URL without credentials and with a valid hostname + type: string + oauth_client_id: + description: >- + OAuth2 client id, required for XOAUTH2 + type: string + oauth_client_secret: + description: >- + OAuth2 client secret, required for XOAUTH2. Never returned by GET. + type: string + oauth_scope: + description: >- + OAuth2 scope, required for XOAUTH2 + type: string + oauth_extra_params: + description: >- + optional extra token request parameters: a JSON object with scalar values, + sent either as object or as JSON string (empty = none). They never + override the grant fields. + oneOf: + - type: object + additionalProperties: true + - type: string + scope: + description: >- + visibility: `all` (admins only), `domain` (needs `domains`) or `user` + (listed `users`; no targets = private to the creator). Users always + create private sources. + type: string + enum: + - all + - domain + - user + domains: + description: target domains when scope is domain + type: array + items: + type: string + users: + description: target mailboxes when scope is user + type: array + items: + type: string + active: + description: >- + enables or disables the source + type: boolean type: object /api/v1/add/tls-policy-map: post: @@ -2782,6 +2989,62 @@ paths: type: object type: object summary: Delete sync job + /api/v1/delete/syncjob_source: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - delete + - source + - id: + - "3" + msg: + - imapsync_source_deleted + - Example import + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Delete one or more sync job sources by id. Sources still referenced by + a sync job cannot be deleted. Delete or + repoint the dependent jobs first. + operationId: Delete sync job source + summary: Delete sync job source + requestBody: + content: + application/json: + schema: + example: + - "3" + items: + type: string + type: array /api/v1/delete/tls-policy-map: post: responses: @@ -3813,18 +4076,21 @@ paths: examples: response: value: - log: - - entity - - action - - object - msg: - - message - - entity name - type: success + - log: + - syncjob + - edit + - job + - id: + - "1" + active: "0" + msg: + - mailbox_modified + - mailbox@domain.tld + type: success schema: properties: log: - description: contains request object + description: contains request object (secrets are masked as "*") items: {} type: array msg: @@ -3842,8 +4108,8 @@ paths: tags: - Sync jobs description: >- - You can update one or more sync jobs per request. You can also send just - attributes you want to change. + Update one or more sync jobs per request. Only send the attributes you + want to change. Requires the `syncjobs` ACL. operationId: Update sync job requestBody: content: @@ -3852,107 +4118,526 @@ paths: example: attr: active: "1" - automap: "1" - custom_params: "" - delete1: "0" - delete2: "0" - delete2duplicates: "1" - enc1: SSL - exclude: (?i)spam|(?i)junk - host1: imap.server.tld - maxage: "0" - maxbytespersecond: "0" - mins_interval: "20" - password1: supersecret - port1: "993" - skipcrossduplicates: "0" - subfolder2: External - subscribeall: "1" - timeout1: "600" - timeout2: "600" + source_id: "3" user1: username - items: "1" + mins_interval: "20" + custom_params: + - {o: folder, v: INBOX} + items: + - "1" properties: attr: properties: - active: - description: Is sync job active - type: boolean - automap: + source_id: description: >- - Try to automap folders ("Sent items", "Sent" => "Sent" - etc.) - type: boolean - custom_params: - description: Custom parameters passed to imapsync command + id of a sync source visible to the caller (`GET /api/v1/get/syncjob_source/all`). + Visibility is only checked when the source changes. + type: integer + user1: + description: >- + remote username at the source. For XOAUTH2 authorization_code sources + a token for (source_id, local mailbox, user1) must exist, i.e. the + mailbox owner must have signed in via `/syncjob-oauth` first + (`imapsync_source_oauth_connect_required` otherwise). For XOAUTH2 + client_credentials sources user1 may only be changed by callers who can + see the source (`access_denied` otherwise); the other fields stay editable. type: string + password1: + description: >- + remote password; empty keeps the stored one. Required when the job + is moved to a non-XOAUTH2 source and no password is stored. + type: string + mins_interval: + description: sync interval in minutes (1-43800) + type: number + subfolder2: + description: sync into subfolder on destination (empty = do not use subfolder) + type: string + maxage: + description: only sync messages up to this age in days (0 = ignore age) + type: number + maxbytespersecond: + description: per-job bandwidth limit in bytes/s (0 = unlimited, capped by the global limit) + type: number + timeout1: + description: timeout for connection to remote host + type: number + timeout2: + description: timeout for connection to local host + type: number + exclude: + description: exclude objects (regex) + type: string + custom_params: + description: >- + Allowlisted imapsync options as a list of option/value pairs (must be + an array). + `o` is the option name without leading dashes (see `GET + /api/v1/get/syncjob_options`), `v` the value. The allowlist decides + per option: `true` = a non-empty value is required, `false` = flag + without value (`v` must be empty), string = regular expression the + whole value must match. Options taking file paths are never allowed. + Values are passed to imapsync as a single argument. + type: array + items: + type: object + properties: + o: + type: string + v: + type: string + prio: + description: >- + priority (admin only, ignored otherwise). Due jobs run ordered by + prio DESC, never-run first, last_run ASC, id ASC. + type: number + last_run: + description: any value resets last_run (job becomes due) + type: string + success: + description: any value resets the last result + type: string + delete2duplicates: + description: delete duplicates on destination (--delete2duplicates) + type: boolean delete1: - description: Delete from source when completed + description: delete from source when completed (--delete1) type: boolean delete2: - description: Delete messages on destination that are not on source + description: delete messages on destination that are not on source (--delete2) type: boolean - delete2duplicates: - description: Delete duplicates on destination + automap: + description: try to automap folders ("Sent items", "Sent" => "Sent" etc.) (--automap) type: boolean + skipcrossduplicates: + description: skip duplicate messages across folders (first come, first serve) (--skipcrossduplicates) + type: boolean + subscribeall: + description: subscribe all folders (--subscribeall) + type: boolean + dry: + description: dry run, nothing is changed (--dry) + type: boolean + active: + description: enables or disables the sync job + type: boolean + host1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + port1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string enc1: - description: Encryption + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + authmech1: + description: >- + Removed. Host, port, encryption and authentication now come from + the sync source (`source_id`, see `/api/v1/add/syncjob_source`). + The attribute is ignored. + deprecated: true + type: string + order_id: + description: Removed, use `prio` (admin) or `/api/v1/edit/syncjob/run_next`. Ignored. + deprecated: true + type: number + type: object + items: + description: list of sync job ids to update + type: array + items: + type: string + type: object + summary: Update sync job + /api/v1/edit/syncjob/run_next: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - edit + - run_next + - id: + - "1" + msg: + - imapsync_run_next_done + - 1 + type: success + schema: + properties: + log: + description: contains request object (secrets are masked as "*") + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Admin only. Moves the given jobs to the front of the queue: `prio` is set + above the current maximum and `last_run` is cleared, so the runner picks + them next. Jobs that are currently running are rejected with + `imapsync_job_running` (one message per job), the others are moved. + The jobs are locked while they are moved, so a job the runner claims at + the same time is reported as running instead of being counted. + operationId: Run sync job next + summary: Run sync job next + requestBody: + content: + application/json: + schema: + example: + items: + - "1" + attr: {} + properties: + items: + description: list of sync job ids + type: array + items: + type: string + attr: + description: unused, send an empty object + type: object + type: object + /api/v1/edit/syncjob_source: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - edit + - source + - id: + - "3" + name: Example import + oauth_client_secret: "*" + msg: + - imapsync_source_modified + - Example import + type: success + schema: + properties: + log: + description: contains request object (secrets are masked as "*") + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Update one or more sync sources (creator, admins, and domain admins for + sources created by mailboxes of their domains). Fields not provided are + left unchanged. All checks run before anything is written; a rejected + edit changes nothing. Requires the `syncjobs` ACL. + operationId: Update sync job source + summary: Update sync job source + requestBody: + content: + application/json: + schema: + example: + items: + - "3" + attr: + name: Example import + host: imap.example.org + port: "993" + enc: SSL + auth_type: PLAIN + active: "1" + properties: + attr: + properties: + name: + description: >- + source name, max. 100 characters, unique per creator + type: string + description: + description: >- + free text shown in the UI, max. 255 characters + type: string + host: + description: >- + remote IMAP host (hostname or IP, max. 253 characters). + `host1` is accepted as an alias. Host, port and enc are only validated + when they change (migrated legacy values stay editable). + type: string + port: + description: >- + remote IMAP port (1-65535). `port1` is accepted as an alias. + type: integer + enc: + description: >- + TLS (STARTTLS) / SSL / PLAIN. `enc1` is accepted as an alias. + type: string enum: - TLS - SSL - PLAIN - type: string - exclude: - description: Exclude objects (regex) - type: string - host1: - description: Hostname - type: string - maxage: + auth_type: description: >- - Maximum age of messages in days that will be polled from - remote (0 = ignore age) - type: number - maxbytespersecond: - description: Max. bytes per second (0 = unlimited) - type: number - mins_interval: - description: Interval (min) - type: number - password1: - description: Password + authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job + password, XOAUTH2 uses OAuth2 tokens. XOAUTH2 sources can only be created + and edited by admins and domain admins (`access_denied` for users); users + can use XOAUTH2 sources visible to them in their sync jobs. type: string - port1: - description: Port - type: string - skipcrossduplicates: + enum: + - PLAIN + - LOGIN + - CRAM-MD5 + - XOAUTH2 + oauth_flow: description: >- - Skip duplicate messages across folders (first come, - first serve) + XOAUTH2 grant type (default client_credentials). client_credentials + sources are always private to their creator (scope is forced to `user` + without targets). + type: string + enum: + - client_credentials + - authorization_code + oauth_authorize_endpoint: + description: >- + authorize URL, required for authorization_code. Must be an https:// URL + without credentials and with a valid hostname + type: string + oauth_token_endpoint: + description: >- + token URL, required for XOAUTH2. Must be an https:// URL without credentials and with a valid hostname + type: string + oauth_userinfo_endpoint: + description: >- + optional identity endpoint for authorization_code (used when the token + response has no usable id_token). Must be an https:// URL without credentials and with a valid hostname + type: string + oauth_client_id: + description: >- + OAuth2 client id, required for XOAUTH2 + type: string + oauth_client_secret: + description: >- + OAuth2 client secret; empty keeps the stored one. Changing auth_type, + oauth_flow, oauth_token_endpoint, oauth_client_id, host, port or enc drops + the cached source token and all per-user tokens of the source (users have + to sign in again). + type: string + oauth_scope: + description: >- + OAuth2 scope, required for XOAUTH2 + type: string + oauth_extra_params: + description: >- + optional extra token request parameters: a JSON object with scalar values, + sent either as object or as JSON string (empty = none). They never + override the grant fields. + oneOf: + - type: object + additionalProperties: true + - type: string + scope: + description: >- + visibility: `all` (admins only), `domain` (needs `domains`) or `user` + (listed `users`; no targets = private to the creator). Users always + create private sources. Targets are only rewritten when `scope` is sent. + type: string + enum: + - all + - domain + - user + domains: + description: target domains when scope is domain + type: array + items: + type: string + users: + description: target mailboxes when scope is user + type: array + items: + type: string + active: + description: >- + enables or disables the source type: boolean - subfolder2: - description: >- - Sync into subfolder on destination (empty = do not use - subfolder) - type: string - subscribeall: - description: Subscribe all folders - type: boolean - timeout1: - description: Timeout for connection to remote host - type: number - timeout2: - description: Timeout for connection to local host - type: number - user1: - description: Username - type: string type: object items: - description: contains list of aliases you want update + description: list of source ids to update + type: array + items: + type: string + type: object + /api/v1/edit/syncjob_source/refresh_token: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - edit + - refresh_token + - id: + - "3" + msg: + - imapsync_source_token_refreshed + - Example import + type: success + schema: + properties: + log: + description: contains request object (secrets are masked as "*") + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Force an OAuth2 access-token refresh for a XOAUTH2 client_credentials + source (other sources: `imapsync_source_refresh_unsupported`). Requires + edit rights on the source and the `syncjobs` ACL. + operationId: Refresh sync job source token + summary: Refresh sync job source token + requestBody: + content: + application/json: + schema: + example: + items: + - "3" + properties: + items: + description: source id (only the first one is used) + type: array + items: + type: string + type: object + /api/v1/edit/imapsync_settings: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - syncjob + - edit + - settings + - max_parallel: "4" + max_kb_per_second: "2000" + msg: max_parallel_saved + type: success + schema: + properties: + log: + description: contains request object (secrets are masked as "*") + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Update global sync job settings (admin only). `max_parallel` is the + number of imapsync processes that may run concurrently (1-32); + `max_kb_per_second` is a per-process bandwidth cap in KB/s (0 = + unlimited, max. 10485760 = 10 GiB/s), stored internally in bytes/s. + operationId: Update sync job settings + summary: Update sync job settings + requestBody: + content: + application/json: + schema: + example: + attr: + max_parallel: "4" + max_kb_per_second: "2000" + properties: + attr: + properties: + max_parallel: + description: max. concurrent sync processes (1-32) + type: number + max_kb_per_second: + description: bandwidth limit per process in KB/s (0 = unlimited, max. 10485760) + type: number type: object type: object - summary: Update sync job /api/v1/edit/user-acl: post: responses: @@ -5607,6 +6292,14 @@ paths: summary: Get version status /api/v1/get/syncjobs/all/no_log: get: + parameters: + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string responses: "401": $ref: "#/components/responses/Unauthorized" @@ -5616,43 +6309,196 @@ paths: examples: response: value: - - active: "1" - authmd51: 0 - authmech1: PLAIN + - id: 1 + user2: mailbox@domain.tld + source_id: 3 + user1: username + active: 1 automap: 1 - created: "2019-05-22 11:37:25" - custom_params: "" + custom_params: '[{"o":"folder","v":"INBOX"}]' + custom_params_legacy: null delete1: 0 delete2: 0 delete2duplicates: 1 - domain2: "" - enc1: TLS + dry: 0 exclude: (?i)spam|(?i)junk - host1: imap.server.tld - id: 1 + exit_status: EX_OK is_running: 0 - last_run: "2019-05-22 11:40:02" + last_run: "2026-10-15 11:40:02" + success: 1 log: "" maxage: 0 maxbytespersecond: "0" - mins_interval: "20" - modified: "2019-05-22 11:40:02" - port1: 993 - regextrans2: "" + mins_interval: 20 + prio: 0 skipcrossduplicates: 0 subfolder2: External subscribeall: 1 timeout1: 600 timeout2: 600 - user1: username - user2: mailbox@domain.tld + created: "2026-10-01 11:37:25" + modified: "2026-10-15 11:40:02" + source_name: Example import + source_description: "" + source_host: imap.example.org + source_port: 993 + source_enc: SSL + source_auth_type: PLAIN + source_oauth_flow: client_credentials + source_created_by: "" + source_scope: all + source_active: 1 + source_oauth_token_expires: null + source_oauth_last_refresh_error: null + user_token_exists: 0 + user_token_expires: null + user_token_error: null description: OK headers: {} tags: - Sync jobs - description: You can list all syn jobs existing in system. + description: >- + Lists all sync jobs the caller may access (`/api/v1/get/syncjobs/all` + includes the log, `/api/v1/get/syncjobs/[/no_log]` filters by + mailbox). Jobs are ordered by prio DESC, never-run first, last_run ASC, + id ASC. + `custom_params_legacy` holds options of migrated jobs that could not be + converted (informational, not used). The removed fields `host1`, + `port1`, `enc1` and `authmech1` are no longer part of a job. operationId: Get sync jobs summary: Get sync jobs + "/api/v1/get/syncjob_source/{id}": + get: + parameters: + - description: source id, or `all` to list every source visible to the session (sorted by name) + example: all + in: path + name: id + required: true + schema: + type: string + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 3 + name: Example import + description: "" + created_by: "" + scope: domain + domains: + - example.org + users: [] + host: imap.example.org + port: 993 + enc: SSL + host1: imap.example.org + port1: 993 + enc1: SSL + auth_type: XOAUTH2 + oauth_flow: authorization_code + oauth_token_endpoint: https://login.example.org/token + oauth_authorize_endpoint: https://login.example.org/authorize + oauth_userinfo_endpoint: null + oauth_client_id: 0000-1111 + oauth_scope: https://outlook.office.com/IMAP.AccessAsUser.All offline_access + oauth_extra_params: null + oauth_redirect_uri: https://mail.example.org/syncjob-oauth + oauth_token_expires: null + oauth_last_refresh_error: null + oauth_last_refresh_attempt: null + has_client_secret: true + has_access_token: false + can_edit: true + active: 1 + created: "2026-10-01 09:00:00" + modified: "2026-10-01 09:00:00" + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Returns a single sync source by id, or all sources visible to the caller + when `id` is `all`. `can_edit` tells whether the caller may edit/delete + the source. + operationId: Get sync job sources + summary: Get sync job sources + /api/v1/get/syncjob_options: + get: + parameters: + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + dry: false + folder: true + maxsize: true + sslargs1: ^SSL_verify_mode=[01]$ + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Returns the allowlist of imapsync options accepted in a sync job's + `custom_params` as option => rule: `true` = requires a non-empty value, + `false` = flag without value, string = regular expression the whole + value must match. + operationId: Get sync job options + summary: Get sync job options + /api/v1/get/imapsync_settings: + get: + parameters: + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + max_parallel: 4 + max_bytes_per_second: 2048000 + max_kb_per_second: 2000 + description: OK + headers: {} + tags: + - Sync jobs + description: >- + Admin only. Returns the global sync job settings (`{}` for other roles). + operationId: Get sync job settings + summary: Get sync job settings "/api/v1/get/tls-policy-map/{id}": get: parameters: diff --git a/data/web/domainadmin/mailbox.php b/data/web/domainadmin/mailbox.php index 8beb5abb9..cc1f5bc1b 100644 --- a/data/web/domainadmin/mailbox.php +++ b/data/web/domainadmin/mailbox.php @@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; +$js_minifier->add('/web/js/site/imapsync.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -42,6 +43,7 @@ $template_data = [ 'lang_rl' => json_encode($lang['ratelimit']), 'lang_edit' => json_encode($lang['edit']), 'lang_datatables' => json_encode($lang['datatables']), + 'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(), ]; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; \ No newline at end of file diff --git a/data/web/edit.php b/data/web/edit.php index 48f2309c1..b003d5bf6 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -204,9 +204,15 @@ if (isset($_SESSION['mailcow_cc_role'])) { if (isset($_GET['syncjob']) && is_numeric($_GET['syncjob'])) { $id = $_GET["syncjob"]; - $result = mailbox('get', 'syncjob_details', $id); + $result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id); $template = 'edit/syncjob.twig'; } + elseif (isset($_GET['syncjob_source']) && + is_numeric($_GET['syncjob_source'])) { + $id = $_GET["syncjob_source"]; + $result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSource($id); + $template = 'edit/imapsync_source.twig'; + } elseif (isset($_GET['filter']) && is_numeric($_GET['filter'])) { $id = $_GET["filter"]; @@ -225,6 +231,7 @@ else { $template_data['access_denied'] = true; } +$js_minifier->add('/web/js/site/imapsync.js'); $js_minifier->add('/web/js/site/edit.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -233,5 +240,6 @@ $template_data['return_to'] = $_SESSION['return_to']; $template_data['lang_user'] = json_encode($lang['user']); $template_data['lang_admin'] = json_encode($lang['admin']); $template_data['lang_datatables'] = json_encode($lang['datatables']); +$template_data['imapsync_redirect_uri'] = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(); require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; diff --git a/data/web/inc/ajax/syncjob_logs.php b/data/web/inc/ajax/syncjob_logs.php index 9c521aec7..5caf2e9f8 100644 --- a/data/web/inc/ajax/syncjob_logs.php +++ b/data/web/inc/ajax/syncjob_logs.php @@ -6,7 +6,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) { } if (isset($_GET['id']) && is_numeric($_GET['id'])) { - if ($details = mailbox('get', 'syncjob_details', intval($_GET['id']))) { + if ($details = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob(intval($_GET['id']))) { echo (empty($details['log'])) ? '-' : $details['log']; } } diff --git a/data/web/inc/footer.inc.php b/data/web/inc/footer.inc.php index 4ced152ae..01dc2c532 100644 --- a/data/web/inc/footer.inc.php +++ b/data/web/inc/footer.inc.php @@ -33,11 +33,11 @@ if (array_key_exists('pending_tfa_methods', $_SESSION)) { if (isset($pending_tfa_authmechs['webauthn'])) { $pending_tfa_authmechs['webauthn'] = true; } - if (!isset($pending_tfa_authmechs['webauthn']) + if (!isset($pending_tfa_authmechs['webauthn']) && isset($pending_tfa_authmechs['yubi_otp'])) { $pending_tfa_authmechs['yubi_otp'] = true; } - if (!isset($pending_tfa_authmechs['webauthn']) + if (!isset($pending_tfa_authmechs['webauthn']) && !isset($pending_tfa_authmechs['yubi_otp']) && isset($pending_tfa_authmechs['totp'])) { $pending_tfa_authmechs['totp'] = true; @@ -72,6 +72,7 @@ $globalVariables = [ 'lang_fido2' => json_encode($lang['fido2']), 'lang_success' => json_encode($lang['success']), 'lang_danger' => json_encode($lang['danger']), + 'lang_syncjobs' => json_encode($lang['syncjobs']), 'docker_timeout' => $DOCKER_TIMEOUT, 'session_lifetime' => (int)$SESSION_LIFETIME, 'csrf_token' => $_SESSION['CSRF']['TOKEN'], diff --git a/data/web/inc/functions.admin.inc.php b/data/web/inc/functions.admin.inc.php index 7bd0af42f..69f1b7e56 100644 --- a/data/web/inc/functions.admin.inc.php +++ b/data/web/inc/functions.admin.inc.php @@ -103,7 +103,7 @@ function admin($_action, $_data = null) { continue; } } - if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) { + if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') { $_SESSION['return'][] = array( 'type' => 'danger', 'log' => array(__FUNCTION__, $_action, $_data_log), @@ -179,6 +179,9 @@ function admin($_action, $_data = null) { $stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username"); $stmt->execute(array(':username_new' => $username_new, ':username' => $username)); } + if ($username_new != $username) { + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new); + } } $_SESSION['return'][] = array( 'type' => 'success', @@ -211,6 +214,7 @@ function admin($_action, $_data = null) { $stmt->execute(array( ':username' => $username, )); + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username); $stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `username` = :username"); $stmt->execute(array( ':username' => $username, diff --git a/data/web/inc/functions.domain_admin.inc.php b/data/web/inc/functions.domain_admin.inc.php index 17aa4ad6a..e2261ea70 100644 --- a/data/web/inc/functions.domain_admin.inc.php +++ b/data/web/inc/functions.domain_admin.inc.php @@ -156,7 +156,7 @@ function domain_admin($_action, $_data = null) { } } } - if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) { + if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') { $_SESSION['return'][] = array( 'type' => 'danger', 'log' => array(__FUNCTION__, $_action, $_data_log), @@ -183,6 +183,9 @@ function domain_admin($_action, $_data = null) { ':username_new' => $username_new, ':username' => $username )); + if ($username_new != $username) { + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new); + } if (!empty($domains)) { foreach ($domains as $domain) { $stmt = $pdo->prepare("INSERT INTO `domain_admins` (`username`, `domain`, `created`, `active`) @@ -331,6 +334,8 @@ function domain_admin($_action, $_data = null) { $stmt->execute(array( ':username' => $username, )); + // sync sources created by this account must not pass to a re-created account of the same name + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username); $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_action, $_data_log), diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 37ccbd85c..645e96a4d 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -302,192 +302,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { 'msg' => array('mailbox_modified', $username) ); break; - case 'syncjob': - if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) { - if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - else { - $username = $_data['username']; - } - } - elseif ($_SESSION['mailcow_cc_role'] == "user") { - $username = $_SESSION['mailcow_cc_username']; - } - else { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'no_user_defined' - ); - return false; - } - $active = intval($_data['active']); - $subscribeall = intval($_data['subscribeall']); - $delete2duplicates = intval($_data['delete2duplicates']); - $delete1 = intval($_data['delete1']); - $delete2 = intval($_data['delete2']); - $timeout1 = intval($_data['timeout1']); - $timeout2 = intval($_data['timeout2']); - $skipcrossduplicates = intval($_data['skipcrossduplicates']); - $automap = intval($_data['automap']); - $dry = intval($_data['dry']); - $port1 = $_data['port1']; - $host1 = strtolower($_data['host1']); - $password1 = $_data['password1']; - $exclude = $_data['exclude']; - $maxage = $_data['maxage']; - $maxbytespersecond = $_data['maxbytespersecond']; - $subfolder2 = $_data['subfolder2']; - $user1 = $_data['user1']; - $mins_interval = $_data['mins_interval']; - $enc1 = $_data['enc1']; - $custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']); - - // validate custom params - foreach (explode('-', $custom_params) as $param){ - if(empty($param)) continue; - - // extract option - if (str_contains($param, '=')) $param = explode('=', $param)[0]; - else $param = rtrim($param, ' '); - // remove first char if first char is - - if ($param[0] == '-') $param = ltrim($param, $param[0]); - - if (str_contains($param, ' ')) { - // bad char - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'bad character SPACE' - ); - return false; - } - - // check if param is whitelisted - if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){ - // bad option - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'bad option '. $param - ); - return false; - } - } - if (empty($subfolder2)) { - $subfolder2 = ""; - } - if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $maxage = "0"; - } - if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $timeout1 = "600"; - } - if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $timeout2 = "600"; - } - if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) { - $maxbytespersecond = "0"; - } - if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - // if (!is_valid_domain_name($host1)) { - // $_SESSION['return'][] = array( - // 'type' => 'danger', - // 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - // 'msg' => 'access_denied' - // ); - // return false; - // } - if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - if (@preg_match("/" . $exclude . "/", null) === false) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - $stmt = $pdo->prepare("SELECT '1' FROM `imapsync` - WHERE `user2` = :user2 AND `user1` = :user1 AND `host1` = :host1"); - $stmt->execute(array(':user1' => $user1, ':user2' => $username, ':host1' => $host1)); - $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); - if ($num_results != 0) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => array('object_exists', htmlspecialchars($host1 . ' / ' . $user1)) - ); - return false; - } - $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`) - VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)"); - $stmt->execute(array( - ':user2' => $username, - ':custom_params' => $custom_params, - ':exclude' => $exclude, - ':maxage' => $maxage, - ':delete1' => $delete1, - ':delete2' => $delete2, - ':timeout1' => $timeout1, - ':timeout2' => $timeout2, - ':automap' => $automap, - ':skipcrossduplicates' => $skipcrossduplicates, - ':maxbytespersecond' => $maxbytespersecond, - ':subscribeall' => $subscribeall, - ':dry' => $dry, - ':subfolder2' => $subfolder2, - ':host1' => $host1, - ':authmech1' => 'PLAIN', - ':user1' => $user1, - ':password1' => $password1, - ':mins_interval' => $mins_interval, - ':port1' => $port1, - ':enc1' => $enc1, - ':delete2duplicates' => $delete2duplicates, - ':active' => $active, - )); - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => array('mailbox_modified', $username) - ); - break; case 'domain': if ($_SESSION['mailcow_cc_role'] != "admin") { $_SESSION['return'][] = array( @@ -2280,202 +2094,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); } break; - case 'syncjob': - if (!is_array($_data['id'])) { - $ids = array(); - $ids[] = $_data['id']; - } - else { - $ids = $_data['id']; - } - if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - foreach ($ids as $id) { - $is_now = mailbox('get', 'syncjob_details', $id, array('with_password')); - if (!empty($is_now)) { - $username = $is_now['user2']; - $user1 = (!empty($_data['user1'])) ? $_data['user1'] : $is_now['user1']; - $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; - $last_run = (isset($_data['last_run'])) ? NULL : $is_now['last_run']; - $success = (isset($_data['success'])) ? NULL : $is_now['success']; - $delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates']; - $subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall']; - $dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry']; - $delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1']; - $delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2']; - $automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap']; - $skipcrossduplicates = (isset($_data['skipcrossduplicates'])) ? intval($_data['skipcrossduplicates']) : $is_now['skipcrossduplicates']; - $port1 = (!empty($_data['port1'])) ? $_data['port1'] : $is_now['port1']; - $password1 = (!empty($_data['password1'])) ? $_data['password1'] : $is_now['password1']; - $host1 = (!empty($_data['host1'])) ? $_data['host1'] : $is_now['host1']; - $subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2']; - $enc1 = (!empty($_data['enc1'])) ? $_data['enc1'] : $is_now['enc1']; - $mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval']; - $exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude']; - $custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params']; - $maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage']; - $maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond']; - $timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1']; - $timeout2 = (isset($_data['timeout2']) && $_data['timeout2'] != "") ? intval($_data['timeout2']) : $is_now['timeout2']; - } - else { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - - // validate custom params - foreach (explode('-', $custom_params) as $param){ - if(empty($param)) continue; - - // extract option - if (str_contains($param, '=')) $param = explode('=', $param)[0]; - else $param = rtrim($param, ' '); - // remove first char if first char is - - if ($param[0] == '-') $param = ltrim($param, $param[0]); - - if (str_contains($param, ' ')) { - // bad char - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'bad character SPACE' - ); - return false; - } - - // check if param is whitelisted - if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){ - // bad option - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'bad option '. $param - ); - return false; - } - } - if (empty($subfolder2)) { - $subfolder2 = ""; - } - if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $maxage = "0"; - } - if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $timeout1 = "600"; - } - if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) { - $timeout2 = "600"; - } - if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) { - $maxbytespersecond = "0"; - } - if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - if (!is_valid_domain_name($host1)) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - if (@preg_match("/" . $exclude . "/", null) === false) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - $stmt = $pdo->prepare("UPDATE `imapsync` SET `delete1` = :delete1, - `delete2` = :delete2, - `automap` = :automap, - `skipcrossduplicates` = :skipcrossduplicates, - `maxage` = :maxage, - `maxbytespersecond` = :maxbytespersecond, - `subfolder2` = :subfolder2, - `exclude` = :exclude, - `host1` = :host1, - `last_run` = :last_run, - `success` = :success, - `user1` = :user1, - `password1` = :password1, - `mins_interval` = :mins_interval, - `port1` = :port1, - `enc1` = :enc1, - `delete2duplicates` = :delete2duplicates, - `custom_params` = :custom_params, - `timeout1` = :timeout1, - `timeout2` = :timeout2, - `subscribeall` = :subscribeall, - `dry` = :dry, - `active` = :active - WHERE `id` = :id"); - $stmt->execute(array( - ':delete1' => $delete1, - ':delete2' => $delete2, - ':automap' => $automap, - ':skipcrossduplicates' => $skipcrossduplicates, - ':id' => $id, - ':exclude' => $exclude, - ':maxage' => $maxage, - ':maxbytespersecond' => $maxbytespersecond, - ':subfolder2' => $subfolder2, - ':host1' => $host1, - ':user1' => $user1, - ':password1' => $password1, - ':last_run' => $last_run, - ':success' => $success, - ':mins_interval' => $mins_interval, - ':port1' => $port1, - ':enc1' => $enc1, - ':delete2duplicates' => $delete2duplicates, - ':custom_params' => $custom_params, - ':timeout1' => $timeout1, - ':timeout2' => $timeout2, - ':subscribeall' => $subscribeall, - ':dry' => $dry, - ':active' => $active, - )); - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => array('mailbox_modified', $username) - ); - } - break; case 'filter': if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) { $_SESSION['return'][] = array( @@ -3652,6 +3270,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } } + // Sync sources created by / targeting the mailbox and its OAuth tokens (owner); a PDOException rolls back below + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username); + // Update c_uid, c_name and mail in _sogo_static_view table $pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username") ->execute([ @@ -4603,68 +4224,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } return false; break; - case 'syncjob_details': - $syncjobdetails = array(); - if (!is_numeric($_data)) { - return false; - } - if (isset($_extra) && in_array('no_log', $_extra)) { - $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("returned_text", "password1")'); - $fields = $field_query->fetchAll(PDO::FETCH_ASSOC); - while($field = array_shift($fields)) { - $shown_fields[] = $field['Field']; - } - $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ", - `active` - FROM `imapsync` WHERE id = :id"); - } - elseif (isset($_extra) && in_array('with_password', $_extra)) { - $stmt = $pdo->prepare("SELECT *, - `active` - FROM `imapsync` WHERE id = :id"); - } - else { - $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("password1")'); - $fields = $field_query->fetchAll(PDO::FETCH_ASSOC); - while($field = array_shift($fields)) { - $shown_fields[] = $field['Field']; - } - $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ", - `active` - FROM `imapsync` WHERE id = :id"); - } - $stmt->execute(array(':id' => $_data)); - $syncjobdetails = $stmt->fetch(PDO::FETCH_ASSOC); - if (!empty($syncjobdetails['returned_text'])) { - $syncjobdetails['log'] = $syncjobdetails['returned_text']; - } - else { - $syncjobdetails['log'] = ''; - } - unset($syncjobdetails['returned_text']); - if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $syncjobdetails['user2'])) { - return false; - } - return $syncjobdetails; - break; - case 'syncjobs': - $syncjobdata = array(); - if (isset($_data) && filter_var($_data, FILTER_VALIDATE_EMAIL)) { - if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) { - return false; - } - } - else { - $_data = $_SESSION['mailcow_cc_username']; - } - $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username"); - $stmt->execute(array(':username' => $_data)); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - while($row = array_shift($rows)) { - $syncjobdata[] = $row['id']; - } - return $syncjobdata; - break; case 'spam_score': $curl = curl_init(); curl_setopt($curl, CURLOPT_UNIX_SOCKET_PATH, '/var/lib/rspamd/rspamd.sock'); @@ -5494,46 +5053,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { break; case 'delete': switch ($_type) { - case 'syncjob': - if (!is_array($_data['id'])) { - $ids = array(); - $ids[] = $_data['id']; - } - else { - $ids = $_data['id']; - } - if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - return false; - } - foreach ($ids as $id) { - if (!is_numeric($id)) { - return false; - } - $stmt = $pdo->prepare("SELECT `user2` FROM `imapsync` WHERE id = :id"); - $stmt->execute(array(':id' => $id)); - $user2 = $stmt->fetch(PDO::FETCH_ASSOC)['user2']; - if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $user2)) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => 'access_denied' - ); - continue; - } - $stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id"); - $stmt->execute(array(':id' => $id)); - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), - 'msg' => array('deleted_syncjob', $id) - ); - } - break; case 'filter': if (!is_array($_data['id'])) { $ids = array(); @@ -5764,6 +5283,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':domain' => $domain, )); + $stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); $stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `domain` = :domain"); $stmt->execute(array( ':domain' => $domain, @@ -6071,6 +5594,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':username' => $username )); + // Tokens, scope targets and own sync sources of the mailbox + \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username); $stmt = $pdo->prepare("DELETE FROM `filterconf` WHERE `object` = :username"); $stmt->execute(array( ':username' => $username diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index f6b0cd792..a9344e6c0 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "18082026_1200"; + $db_version = "16102026_1200"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -755,35 +755,153 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "imapsync_source" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "name" => "VARCHAR(100) NOT NULL", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", + "created_by" => "VARCHAR(255) NOT NULL DEFAULT ''", + "scope" => "ENUM('all','domain','user') NOT NULL DEFAULT 'user'", + "host1" => "VARCHAR(255) NOT NULL", + "port1" => "SMALLINT UNSIGNED NOT NULL", + "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'", + "auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'", + "oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'", + "oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL", + "oauth_authorize_endpoint" => "VARCHAR(500) DEFAULT NULL", + "oauth_userinfo_endpoint" => "VARCHAR(500) DEFAULT NULL", + "oauth_client_id" => "VARCHAR(500) DEFAULT NULL", + "oauth_client_secret" => "VARCHAR(500) DEFAULT NULL", + "oauth_scope" => "VARCHAR(500) DEFAULT NULL", + "oauth_extra_params" => "TEXT DEFAULT NULL", + "oauth_access_token" => "TEXT DEFAULT NULL", + "oauth_token_expires" => "BIGINT UNSIGNED DEFAULT NULL", + "oauth_last_refresh_error" => "TEXT DEFAULT NULL", + "oauth_last_refresh_attempt" => "DATETIME NULL DEFAULT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "uniq_creator_name" => array("created_by", "name") + ), + "key" => array( + "idx_created_by" => array("created_by"), + "idx_scope" => array("scope") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "imapsync_source_domain" => array( + "cols" => array( + "source_id" => "INT NOT NULL", + "domain" => "VARCHAR(255) NOT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("source_id", "domain") + ), + "key" => array( + "idx_domain" => array("domain") + ), + "fkey" => array( + "fk_imapsync_source_domain" => array( + "col" => "source_id", + "ref" => "imapsync_source.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "imapsync_source_user" => array( + "cols" => array( + "source_id" => "INT NOT NULL", + "username" => "VARCHAR(255) NOT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("source_id", "username") + ), + "key" => array( + "idx_username" => array("username") + ), + "fkey" => array( + "fk_imapsync_source_user" => array( + "col" => "source_id", + "ref" => "imapsync_source.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "imapsync_source_oauth_token" => array( + "cols" => array( + "source_id" => "INT NOT NULL", + "owner" => "VARCHAR(255) NOT NULL", + "username" => "VARCHAR(255) NOT NULL", + "access_token" => "TEXT DEFAULT NULL", + "refresh_token" => "TEXT DEFAULT NULL", + "token_expires" => "BIGINT UNSIGNED DEFAULT NULL", + "last_refresh_error" => "TEXT DEFAULT NULL", + "oauth_last_refresh_attempt" => "DATETIME NULL DEFAULT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("source_id", "owner", "username") + ), + "key" => array( + "idx_owner" => array("owner") + ), + "fkey" => array( + "fk_imapsync_source_oauth_token" => array( + "col" => "source_id", + "ref" => "imapsync_source.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "imapsync" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", "user2" => "VARCHAR(255) NOT NULL", - "host1" => "VARCHAR(255) NOT NULL", - "authmech1" => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT 'PLAIN'", + "source_id" => "INT NOT NULL", "regextrans2" => "VARCHAR(255) DEFAULT ''", "authmd51" => "TINYINT(1) NOT NULL DEFAULT 0", "domain2" => "VARCHAR(255) NOT NULL DEFAULT ''", "subfolder2" => "VARCHAR(255) NOT NULL DEFAULT ''", "user1" => "VARCHAR(255) NOT NULL", - "password1" => "VARCHAR(255) NOT NULL", + "password1" => "VARCHAR(255) NOT NULL DEFAULT ''", "exclude" => "VARCHAR(500) NOT NULL DEFAULT ''", "maxage" => "SMALLINT NOT NULL DEFAULT '0'", "mins_interval" => "SMALLINT UNSIGNED NOT NULL DEFAULT '0'", "maxbytespersecond" => "VARCHAR(50) NOT NULL DEFAULT '0'", - "port1" => "SMALLINT UNSIGNED NOT NULL", - "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'", "delete2duplicates" => "TINYINT(1) NOT NULL DEFAULT '1'", "delete1" => "TINYINT(1) NOT NULL DEFAULT '0'", "delete2" => "TINYINT(1) NOT NULL DEFAULT '0'", "automap" => "TINYINT(1) NOT NULL DEFAULT '0'", "skipcrossduplicates" => "TINYINT(1) NOT NULL DEFAULT '0'", - "custom_params" => "VARCHAR(512) NOT NULL DEFAULT ''", + "custom_params" => "TEXT", + // original legacy custom_params string, only set when options were dropped during migration (never used by the runner) + "custom_params_legacy" => "TEXT DEFAULT NULL", "timeout1" => "SMALLINT NOT NULL DEFAULT '600'", "timeout2" => "SMALLINT NOT NULL DEFAULT '600'", "subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'", "dry" => "TINYINT(1) NOT NULL DEFAULT '0'", "is_running" => "TINYINT(1) NOT NULL DEFAULT '0'", + "prio" => "INT NOT NULL DEFAULT 0", "returned_text" => "LONGTEXT", "last_run" => "TIMESTAMP NULL DEFAULT NULL", "success" => "TINYINT(1) UNSIGNED DEFAULT NULL", @@ -795,6 +913,32 @@ function init_db_schema() "keys" => array( "primary" => array( "" => array("id") + ), + "key" => array( + "idx_source_id" => array("source_id"), + "idx_prio" => array("prio") + ), + "fkey" => array( + "fk_imapsync_source" => array( + "col" => "source_id", + "ref" => "imapsync_source.id", + "delete" => "RESTRICT", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "imapsync_settings" => array( + "cols" => array( + "name" => "VARCHAR(64) NOT NULL", + "value" => "VARCHAR(255) NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("name") ) ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" @@ -1184,11 +1328,128 @@ function init_db_schema() } } + // Migrate legacy imapsync jobs (host/port/enc per job) to domain-scoped legacy sources + // Re-entrant: only rows with source_id IS NULL are migrated, the legacy columns are kept while any are left + if ($table == 'imapsync') { + $stmt = $pdo->query("SHOW TABLES LIKE 'imapsync'"); + if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0) { + $imapsync_cols = array_column($pdo->query("SHOW COLUMNS FROM `imapsync`")->fetchAll(PDO::FETCH_ASSOC), 'Field', 'Field'); + if (isset($imapsync_cols['host1'])) { + if (!isset($imapsync_cols['source_id'])) { + $pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `source_id` INT DEFAULT NULL"); + } + if (!isset($imapsync_cols['custom_params_legacy'])) { + $pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `custom_params_legacy` TEXT DEFAULT NULL"); + } + // Converted JSON may not fit into the old VARCHAR(512) + $pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT"); + $imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []); + $source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name"); + $source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`) + VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)"); + $source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)"); + $job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy, + `dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL"); + $stmt = $pdo->query("SELECT `id`, `user2`, `host1`, `port1`, `enc1`, `custom_params` FROM `imapsync` WHERE `source_id` IS NULL"); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + // Parse the legacy custom_params string like the old runner did (all tokens up to the next option are its value) + // into pairs valid under $IMAPSYNC_OPTIONS, a token after a flag is never its value. + // --authmech1 PLAIN|LOGIN|CRAM-MD5 becomes the source auth type, --dry the job's dry flag (never run a simulation for real). + // Anything else that does not fit is dropped, the original string is then kept in custom_params_legacy. + $pairs = array(); + $auth = null; + $dry = false; + $dropped = false; + $tokens = preg_split('/\s+/', trim((string)$row['custom_params']), -1, PREG_SPLIT_NO_EMPTY); + for ($i = 0, $n = count($tokens); $i < $n; $i++) { + if ($tokens[$i][0] !== '-') { + // stray value without option + $dropped = true; + continue; + } + @list($opt, $val) = explode('=', ltrim($tokens[$i], '-'), 2); + $opt = strtolower($opt); + $inline = ($val !== null); + $rest = array(); + while ($i + 1 < $n && $tokens[$i + 1][0] !== '-') { + $rest[] = $tokens[++$i]; + } + $rule = ($opt === 'authmech1') ? true : ($imapsync_allow[$opt] ?? null); + if ($opt === 'dry' || $rule === false) { + if ($inline || !empty($rest)) $dropped = true; + if ($opt === 'dry') { + $dry = true; + } elseif (!$inline) { + $pairs[] = array('o' => $opt, 'v' => ''); + } + continue; + } + // unknown option, or "--opt=a b" (the old runner cut the value at the first space) + if ($rule === null || ($inline && !empty($rest))) { + $dropped = true; + continue; + } + $val = $inline ? $val : implode(' ', $rest); + if ($opt === 'authmech1' && $auth === null && in_array(strtoupper($val), array('PLAIN', 'LOGIN', 'CRAM-MD5'), true)) { + $auth = strtoupper($val); + } elseif ($opt === 'authmech1' || trim($val) === '' + || (is_string($rule) && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $val) !== 1)) { + $dropped = true; + } else { + $pairs[] = array('o' => $opt, 'v' => $val); + } + } + // Old runner ignored the authmech1 column, only a custom --authmech1 was effective + $auth = $auth ?? 'PLAIN'; + $host = strtolower(trim((string)$row['host1'])); + $port = (intval($row['port1']) >= 1 && intval($row['port1']) <= 65535) ? intval($row['port1']) : 143; + $enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS'; + $at = strrchr((string)$row['user2'], '@'); + $domain = ($at === false) ? '' : strtolower(substr($at, 1)); + // One source per (host, port, enc, auth, domain), name always unique and <= 100 chars + $name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8); + $source_sel->execute(array(':name' => $name)); + if (($source_id = $source_sel->fetchColumn()) === false) { + $source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth)); + $source_sel->execute(array(':name' => $name)); + if (($source_id = $source_sel->fetchColumn()) === false) continue; + } + if ($domain !== '') { + $source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain)); + } + $job_upd->execute(array( + ':source_id' => $source_id, + ':cp' => json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ':cp_legacy' => $dropped ? $row['custom_params'] : null, + ':dry' => $dry ? 1 : 0, + ':id' => $row['id'] + )); + } + + // Never throw on leftovers: keep source_id nullable and the legacy columns, retried with the next db_version + $pending = intval($pdo->query("SELECT COUNT(*) FROM `imapsync` WHERE `source_id` IS NULL")->fetchColumn()); + if ($pending > 0) { + $properties['cols']['source_id'] = "INT DEFAULT NULL"; + $legacy_col_types = array( + 'host1' => "VARCHAR(255) DEFAULT NULL", + 'port1' => "SMALLINT UNSIGNED DEFAULT NULL", + 'enc1' => "ENUM('TLS','SSL','PLAIN') DEFAULT NULL", + 'authmech1' => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT NULL" + ); + foreach ($legacy_col_types as $col => $type) { + if (isset($imapsync_cols[$col])) $properties['cols'][$col] = $type; + } + error_log('mailcow: imapsync migration: ' . $pending . ' sync job(s) could not be assigned to a source and will not run'); + } + } + } + } + $stmt = $pdo->query("SHOW TABLES LIKE '" . $table . "'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); if ($num_results != 0) { $stmt = $pdo->prepare("SELECT CONCAT('ALTER TABLE `', `table_schema`, '`.', `table_name`, ' DROP FOREIGN KEY ', `constraint_name`, ';') AS `FKEY_DROP` FROM `information_schema`.`table_constraints` - WHERE `constraint_type` = 'FOREIGN KEY' AND `table_name` = :table;"); + WHERE `constraint_type` = 'FOREIGN KEY' AND `table_schema` = DATABASE() AND `table_name` = :table;"); $stmt->execute(array(':table' => $table)); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($row = array_shift($rows)) { @@ -1349,20 +1610,29 @@ function init_db_schema() AND (JSON_CONTAINS_PATH(`call`, 'one', '$[2].password') OR JSON_CONTAINS_PATH(`call`, 'one', '$[2].password2'));"); - // Mitigate imapsync argument injection issue - $pdo->query("UPDATE `imapsync` SET `custom_params` = '' - WHERE `custom_params` LIKE '%pipemess%' - OR custom_params LIKE '%skipmess%' - OR custom_params LIKE '%delete2foldersonly%' - OR custom_params LIKE '%delete2foldersbutnot%' - OR custom_params LIKE '%regexflag%' - OR custom_params LIKE '%pipemess%' - OR custom_params LIKE '%regextrans2%' - OR custom_params LIKE '%maxlinelengthcmd%';"); + // Mask syncjob secrets in old log entries (data and attr arguments of the logged call) + $mask_paths = array(); + $unmasked = array(); + foreach (array(3, 4) as $idx) { + foreach (array('password1', 'password2', 'oauth_client_secret', 'oauth_extra_params') as $key) { + $mask_paths[] = "'$[" . $idx . "]." . $key . "'"; + // only rows still holding a value other than the string '*' (objects/arrays/numbers included), missing paths are NULL + $unmasked[] = "JSON_UNQUOTE(JSON_EXTRACT(`call`, '$[" . $idx . "]." . $key . "')) <> '*'"; + } + } + $pdo->exec("UPDATE `logs` SET `call` = JSON_REPLACE(`call`, " . implode(", '*', ", $mask_paths) . ", '*') + WHERE JSON_VALID(`call`) + AND (JSON_VALUE(`call`, '$[0]') = 'syncjob' + OR JSON_VALUE(`call`, '$[0]') LIKE 'imapsync%' + OR (JSON_VALUE(`call`, '$[0]') = 'mailbox' AND JSON_VALUE(`call`, '$[2]') = 'syncjob')) + AND (" . implode(" OR ", $unmasked) . ");"); // Migrate webauthn tfa $stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')"); + // Syncjobs: seed global settings + $pdo->query("INSERT IGNORE INTO `imapsync_settings` (`name`, `value`) VALUES ('max_parallel', '1')"); + // Inject admin if not exists $stmt = $pdo->query("SELECT NULL FROM `admin`"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -1544,6 +1814,10 @@ function init_db_schema() } if (php_sapi_name() == "cli") { include '/web/inc/vars.inc.php'; + if (file_exists('/web/inc/vars.local.inc.php')) { + include '/web/inc/vars.local.inc.php'; + } + require_once '/web/inc/lib/vendor/autoload.php'; include '/web/inc/functions.inc.php'; include '/web/inc/functions.docker.inc.php'; // $now = new DateTime(); @@ -1561,7 +1835,7 @@ if (php_sapi_name() == "cli") { //PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '" . $offset . "', group_concat_max_len = 3423543543;", ]; $pdo = new PDO($dsn, $database_user, $database_pass, $opt); - $stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view';"); + $stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view');"); $res = $stmt->fetch(PDO::FETCH_ASSOC); if (intval($res['OK_C']) === 2) { // Be more precise when replacing into _sogo_static_view, col orders may change diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php index 72c5da67a..bdf0d3a34 100644 --- a/data/web/inc/vars.inc.php +++ b/data/web/inc/vars.inc.php @@ -287,131 +287,98 @@ $RSPAMD_MAPS = array( ); -$IMAPSYNC_OPTIONS = array( - 'whitelist' => array( - 'abort', - 'authmd51', - 'authmd52', - 'authmech1', - 'authmech2', - 'authuser1', - 'authuser2', - 'debug', - 'debugcontent', - 'debugcrossduplicates', - 'debugflags', - 'debugfolders', - 'debugimap', - 'debugimap1', - 'debugimap2', - 'debugmemory', - 'debugssl', - 'delete1emptyfolders', - 'delete2folders', - 'disarmreadreceipts', - 'domain1', - 'domain2', - 'domino1', - 'domino2', - 'dry', - 'errorsmax', - 'exchange1', - 'exchange2', - 'exitwhenover', - 'expunge1', - 'f1f2', - 'filterbuggyflags', - 'folder', - 'folderfirst', - 'folderlast', - 'folderrec', - 'gmail1', - 'gmail2', - 'idatefromheader', - 'include', - 'inet4', - 'inet6', - 'justconnect', - 'justfolders', - 'justfoldersizes', - 'justlogin', - 'keepalive1', - 'keepalive2', - 'log', - 'logdir', - 'logfile', - 'maxbytesafter', - 'maxlinelength', - 'maxmessagespersecond', - 'maxsize', - 'maxsleep', - 'minage', - 'minsize', - 'noabletosearch', - 'noabletosearch1', - 'noabletosearch2', - 'noexpunge1', - 'noexpunge2', - 'nofoldersizesatend', - 'noid', - 'nolog', - 'nomixfolders', - 'noresyncflags', - 'nossl1', - 'nossl2', - 'nosyncacls', - 'notls1', - 'notls2', - 'nouidexpunge2', - 'nousecache', - 'oauthaccesstoken1', - 'oauthaccesstoken2', - 'oauthdirect1', - 'oauthdirect2', - 'office1', - 'office2', - 'pidfile', - 'pidfilelocking', - 'prefix1', - 'prefix2', - 'proxyauth1', - 'proxyauth2', - 'resyncflags', - 'resynclabels', - 'search', - 'search1', - 'search2', - 'sep1', - 'sep2', - 'showpasswords', - 'skipemptyfolders', - 'ssl2', - 'sslargs1', - 'sslargs2', - 'subfolder1', - 'subscribe', - 'subscribed', - 'syncacls', - 'syncduplicates', - 'syncinternaldates', - 'synclabels', - 'tests', - 'testslive', - 'testslive6', - 'tls2', - 'truncmess', - 'usecache', - 'useheader', - 'useuid' - ), - 'blacklist' => array( - 'skipmess', - 'delete2foldersonly', - 'delete2foldersbutnot', - 'regexflag', - 'regexmess', - 'pipemess', - 'regextrans2', - 'maxlinelengthcmd' - ) +// Allowed imapsync options for a sync job's custom_params (key = option name, lowercase, without dashes): +// true = option requires a non-empty value +// false = pure flag, must NOT have a value +// string = PCRE (no delimiters) the whole non-empty value must match +// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are). +// Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']); +// The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged). +// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd' +// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file) +$IMAPSYNC_OPTIONS_DEFAULT = array( + 'abort' => false, + 'authmd51' => false, + 'authuser1' => true, + 'debug' => false, + 'debugcontent' => false, + 'debugcrossduplicates' => false, + 'debugflags' => false, + 'debugfolders' => false, + 'debugimap' => false, + 'debugimap1' => false, + 'debugmemory' => false, + 'debugssl' => true, + 'delete1emptyfolders' => false, + 'delete2folders' => false, + 'disarmreadreceipts' => false, + 'domain1' => true, + 'domino1' => false, + 'errorsmax' => true, + 'exchange1' => false, + 'exitwhenover' => true, + 'expunge1' => false, + 'f1f2' => true, + 'filterbuggyflags' => false, + 'folder' => true, + 'folderfirst' => true, + 'folderlast' => true, + 'folderrec' => true, + 'gmail1' => false, + 'idatefromheader' => false, + 'include' => true, + 'inet4' => false, + 'inet6' => false, + 'justconnect' => false, + 'justfolders' => false, + 'justfoldersizes' => false, + 'justlogin' => false, + 'keepalive1' => false, + 'log' => false, + 'maxbytesafter' => true, + 'maxlinelength' => true, + 'maxmessagespersecond' => true, + 'maxsize' => true, + 'maxsleep' => true, + 'minage' => true, + 'minsize' => true, + 'noabletosearch' => false, + 'noabletosearch1' => false, + 'noexpunge1' => false, + 'nofoldersizesatend' => false, + 'noid' => false, + 'nolog' => false, + 'nomixfolders' => false, + 'noresyncflags' => false, + 'nossl1' => false, + 'nosyncacls' => false, + 'notls1' => false, + 'nousecache' => false, + 'office1' => false, + 'prefix1' => true, + 'prefix2' => true, + 'proxyauth1' => false, + 'resyncflags' => false, + 'resynclabels' => false, + 'search' => true, + 'search1' => true, + 'sep1' => true, + 'sep2' => true, + 'skipemptyfolders' => false, + 'sslargs1' => '^SSL_verify_mode=[01]$', + 'subfolder1' => true, + 'subscribe' => false, + 'subscribed' => false, + 'syncacls' => false, + 'syncduplicates' => false, + 'syncinternaldates' => false, + 'synclabels' => false, + 'tests' => false, + 'testslive' => false, + 'testslive6' => false, + 'truncmess' => true, + 'usecache' => false, + 'useheader' => true, + 'useuid' => false, ); +$IMAPSYNC_OPTIONS = $IMAPSYNC_OPTIONS_DEFAULT; diff --git a/data/web/js/build/013-mailcow.js b/data/web/js/build/013-mailcow.js index 9586af33d..d9182316a 100644 --- a/data/web/js/build/013-mailcow.js +++ b/data/web/js/build/013-mailcow.js @@ -73,7 +73,8 @@ $(document).ready(function() { } // form cache - $('[data-cached-form="true"]').formcache({key: $(this).data('id')}); + // fields marked with data-formcache-skip (secrets) are never written to localStorage + $('[data-cached-form="true"]').formcache({key: $(this).data('id'), controls: ['select', 'textarea', 'input:not([data-formcache-skip])']}); // tooltips $(function () { @@ -113,6 +114,8 @@ $(document).ready(function() { } localStorage.setItem(key, tab_id); + // remember which card of the tab was opened + localStorage.setItem(key + ':collapse', $(this).attr('data-bs-target').substring(1)); }); // open last tab $('[role="tablist"]').each(function (idx, elem) { @@ -128,7 +131,10 @@ $(document).ready(function() { dropdown.find('.dropdown-menu').removeClass('show'); dropdown.find('.dropdown-toggle').attr('aria-expanded', 'false'); var tab = $('[id^="' + lastTab + '"]'); - $(tab).find('.card-body.collapse:first').collapse('show'); + var lastCollapse = localStorage.getItem(key + ':collapse'); + var $card = lastCollapse ? $(tab).find('.card-body.collapse').filter(function() { return this.id === lastCollapse; }) : $(); + if (!$card.length) $card = $(tab).find('.card-body.collapse:first'); + $card.collapse('show'); } }); })(); diff --git a/data/web/js/site/imapsync.js b/data/web/js/site/imapsync.js new file mode 100644 index 000000000..bcf321e00 --- /dev/null +++ b/data/web/js/site/imapsync.js @@ -0,0 +1,707 @@ +// IMAP sync jobs and sync sources (admin/domainadmin mailbox page, user page, edit page). +// Loaded via $js_minifier only on pages that show sync jobs. +var mailcowImapsync = (function($) { + 'use strict'; + + // Lang strings: lang.syncjobs (mailbox/user page) or lang_syncjobs (edit page), '' if missing + function L(key) { + var src = (typeof lang_syncjobs !== 'undefined' && lang_syncjobs) ? lang_syncjobs : + ((typeof lang !== 'undefined' && lang && lang.syncjobs) ? lang.syncjobs : {}); + return (src[key] !== undefined && src[key] !== null) ? src[key] : ''; + } + function esc(s) { + return escapeHtml(s === null || s === undefined ? '' : s); + } + // same format as "last run" (YYYY-MM-DD HH:mm:ss), independent of the browser locale + function formatDate(ts) { + if (!ts) return ''; + var d = new Date(ts * 1000); + var p = function(n) { return (n < 10 ? '0' : '') + n; }; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + } + function isAdmin() { + return typeof mailcow_cc_role !== 'undefined' && mailcow_cc_role === 'admin'; + } + + // bootstrap-select: refresh an existing instance, or init with the mailcow defaults + function refreshSelect($sel) { + if (typeof $sel.selectpicker !== 'function') return; + $sel.each(function() { + var $s = $(this); + if ($s.data('selectpicker')) { + $s.selectpicker('refresh'); + } else { + $s.selectpicker({ + 'styleBase': 'btn btn-xs-lg', + 'noneSelectedText': (typeof lang_footer !== 'undefined') ? lang_footer.nothing_selected : '' + }); + } + }); + } + + // Long source labels must not overflow the modal on small screens + function injectStyle() { + if ($('#imapsync-style').length) return; + $('head').append(''); + } + + // ===== Display helpers (tables) ===== + + // Visibility label of a source row + function scopeDisplay(item) { + if (item.scope === 'all') return ' ' + esc(L('source_scope_all')); + if (item.scope === 'domain') return esc(L('source_scope_domain') + ': ' + (item.domains || []).join(', ')); + var us = item.users || []; + if (us.length === 0) return esc(item.created_by || ''); + return esc(L('source_scope_user') + ': ' + us.join(', ')); + } + + // Token badge of a client_credentials source: based on token existence/error, expiry in title + function sourceTokenBadge(item) { + if (item.oauth_last_refresh_error) { + return '' + esc(L('token_state_failed')) + ''; + } + if (item.has_access_token) { + var title = item.oauth_token_expires ? L('source_token_status') + ': ' + formatDate(item.oauth_token_expires) : ''; + return '' + esc(L('token_state_issued')) + ''; + } + return '' + esc(L('token_state_none')) + ''; + } + + // Auth column of a source row: auth type, and for XOAUTH2 the flow (+ token badge for client credentials) + function authDisplay(item) { + var out = esc(item.auth_type); + if (item.auth_type !== 'XOAUTH2') return out; + var flow = item.oauth_flow || 'client_credentials'; + out += ' (' + esc(flow === 'authorization_code' ? L('source_flow_badge_user') : L('source_flow_badge_app')) + ')'; + // authorization_code tokens live per job (local mailbox + remote user), not on the source + if (flow === 'authorization_code') return out; + return out + '
' + sourceTokenBadge(item) + '
'; + } + + // Per-job token badge for authorization_code sources ('' for everything else) + function jobTokenBadge(item) { + if (item.source_auth_type !== 'XOAUTH2' || item.source_oauth_flow !== 'authorization_code') return ''; + if (item.user_token_error) { + return '' + esc(L('token_state_failed')) + ''; + } + if (parseInt(item.user_token_exists) === 1) { + var title = item.user_token_expires ? L('source_token_status') + ': ' + formatDate(item.user_token_expires) : ''; + return '' + esc(L('token_state_issued')) + ''; + } + return '' + esc(L('token_state_pending')) + ''; + } + + // Warning badge for jobs with not migrated legacy custom parameters + function legacyBadge(item) { + if (!item.custom_params_legacy) return ''; + return '' + + ' ' + esc(L('custom_params_legacy_badge')) + ''; + } + + // Wrappable table cell content (long hosts, mailboxes, legacy source names) + function wrapCell(html) { + return '
' + html + '
'; + } + + // Server column: "remote user @ host:port", source name below, badges after + function serverDisplay(item) { + var out = esc(item.user1 || '') + ' @ ' + esc(item.source_host || '?') + ':' + esc(item.source_port || '?'); + if (item.source_name) out += '
' + esc(item.source_name) + '
'; + var badges = [jobTokenBadge(item), legacyBadge(item)].filter(Boolean); + if (badges.length) out += '
' + badges.join(' ') + '
'; + return wrapCell(out); + } + + // Prepares the shared fields of a job row (run state, last run, result) + function prepareJobRow(item) { + item.server_w_port = serverDisplay(item); + if (parseInt(item.is_running) === 1) { + item.is_running = '' + esc(L('status_running')) + ''; + } else if (parseInt(item.active) !== 1) { + item.is_running = '' + esc(L('status_inactive')) + ''; + } else { + item.is_running = '' + esc(L('status_waiting')) + ''; + } + if (!item.last_run) { + item.last_run = '' + esc(L('never_run')) + ''; + } + if (item.success == null) { + item.success = '-'; + item.exit_status = ''; + } else { + item.success = ''; + } + if (L('syncjob_' + item.exit_status)) { + item.exit_status = L('syncjob_' + item.exit_status); + } else if (item.success != '-') { + item.exit_status = L('syncjob_check_log'); + } + item.exit_status = item.success + ' ' + esc(item.exit_status); + return item; + } + + // Job tables: ignore DataTables state saved before the rework (old ID sort, column visibility) + var JOB_TABLE_STATE_VERSION = 2; + function jobTableStateSave(settings, data) { + data.imapsync_state = JOB_TABLE_STATE_VERSION; + } + function jobTableStateLoad(settings, data) { + if (!data || data.imapsync_state !== JOB_TABLE_STATE_VERSION) return false; + // column visibility depends on the role, never restore it + if (Array.isArray(data.columns)) { + data.columns.forEach(function(c) { if (c) delete c.visible; }); + } + } + + // Sync source table (mailbox + user page). canManage: session may add/edit sources at all. + function drawSourceTable(canManage) { + if ($.fn.DataTable.isDataTable('#imapsync_source_table')) { + $('#imapsync_source_table').DataTable().columns.adjust().responsive.recalc(); + return; + } + $('#imapsync_source_table').DataTable({ + responsive: true, + processing: true, + serverSide: false, + stateSave: true, + pageLength: pagination_size, + dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" + + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", + language: lang_datatables, + order: [[2, 'asc']], + ajax: { + type: "GET", + url: "/api/v1/get/syncjob_source/all", + dataSrc: function(json) { + if (!Array.isArray(json)) return []; + $.each(json, function(i, item) { + item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')')); + item.name_display = esc(item.name); + if (item.description) item.name_display += '
' + esc(item.description) + '
'; + item.name_display = wrapCell(item.name_display); + item.auth_display = authDisplay(item); + item.visibility_display = wrapCell(scopeDisplay(item)); + if (canManage && item.can_edit) { + item.action = ''; + } else { + item.action = ' ' + esc(L('source_view')) + ''; + } + }); + return json; + }, + error: function() { + mailcow_alert_box(L('source_list_error'), 'danger'); + } + }, + columns: [ + { + // placeholder, so the responsive control does not cover the ID + title: '', + data: null, + searchable: false, + orderable: false, + defaultContent: '', + responsivePriority: 1 + }, + { title: L('id'), data: 'id', defaultContent: '', responsivePriority: 4 }, + { title: L('source_name'), data: 'name_display', defaultContent: '', responsivePriority: 2 }, + { title: L('source_scope'), data: 'visibility_display', defaultContent: '', responsivePriority: 6 }, + { title: L('source_server'), data: 'endpoint', defaultContent: '', responsivePriority: 5 }, + { title: L('source_auth_type'), data: 'auth_display', defaultContent: '', responsivePriority: 3 }, + { + title: lang.active, + data: 'active', + defaultContent: '', + responsivePriority: 7, + render: function(d) { return d == 1 ? '' : ''; } + }, + { + title: lang.action, + data: 'action', + orderable: false, + className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right', + responsivePriority: 3, + defaultContent: '' + } + ] + }); + } + + // ===== Source dropdown in the job modal / edit page ===== + + function setNoSourceState($sel, empty) { + $sel.closest('form').find('.imapsync-no-source').toggleClass('d-none', !empty); + } + + // Replace the options of a source dropdown (array of jQuery options, kept in order) and select keepId (if present and enabled). + // bootstrap-select 1.14 keeps a detached title option (incl. its selectedness) across empty()/refresh, + // which overrides the selected option and leaves stale list entries, so destroy and re-init instead. + function rebuildSourceSelect($sel, $options, keepId) { + if (typeof $sel.selectpicker === 'function' && $sel.data('selectpicker')) $sel.selectpicker('destroy'); + $sel.find('option').remove(); + $sel.append($options); + var $keep = (keepId !== '') ? $sel.find('option').filter(function() { + return $(this).val() === keepId && !$(this).prop('disabled'); + }).first() : $(); + $sel.find('option').prop('selected', false).removeAttr('selected'); + if ($keep.length) { + $keep.attr('selected', 'selected').prop('selected', true); + } else { + // nothing to keep: select nothing, the required check makes the user pick a source + $sel.prop('selectedIndex', -1); + } + $sel.prop('disabled', false); + refreshSelect($sel); + if ($keep.length && $sel.data('selectpicker')) $sel.selectpicker('val', keepId); + return $keep.length > 0; + } + + // (Re)build every source dropdown from the ACL-filtered API + function populateSourceSelects() { + var $selects = $('select.imapsync-source-select'); + if (!$selects.length) return; + $selects.each(function() { + var $sel = $(this); + if ($sel.val()) $sel.data('keep-id', String($sel.val())); + $sel.data('saved-options', $sel.find('option:not(.bs-title-option)').clone()); + // disable while loading instead of a transient placeholder option + $sel.prop('disabled', true); + refreshSelect($sel); + }); + $.get('/api/v1/get/syncjob_source/all').done(function(sources) { + if (!Array.isArray(sources)) sources = []; + sources.sort(function(a, b) { return String(a.name).localeCompare(String(b.name)); }); + $selects.each(function() { + var $sel = $(this); + var cur = String($sel.data('current-source-id') || ''); + var keepId = String($sel.data('keep-id') || cur); + var opts = []; + var selectable = 0; + var listed = false; + $.each(sources, function(_, src) { + var id = String(src.id); + var $opt = sourceOption(src.id, src.name, src.host || src.host1, src.port || src.port1, src.auth_type, src.oauth_flow); + if (src.active != 1) { + $opt.attr('data-subtext', $opt.attr('data-subtext') + ' · ' + L('status_inactive')); + // the backend rejects inactive sources, only the assigned one stays selectable on the edit page + if (id !== cur) $opt.prop('disabled', true); + } + if (!$opt.prop('disabled')) selectable++; + if (id === cur) listed = true; + opts.push($opt); + }); + // edit page: the assigned source stays selectable even if the viewer cannot see it + if (cur !== '' && !listed) { + var $cur = sourceOption(cur, $sel.data('current-source-name'), $sel.data('current-source-host'), + $sel.data('current-source-port'), $sel.data('current-source-auth-type'), $sel.data('current-source-oauth-flow')); + $cur.text($cur.text() + ' ' + L('source_not_visible')); + opts.unshift($cur); + selectable++; + } + rebuildSourceSelect($sel, opts, keepId); + setNoSourceState($sel, selectable === 0); + $sel.trigger('change'); + }); + }).fail(function() { + // keep what was there before (e.g. the assigned source on the edit page) + $selects.each(function() { + var $sel = $(this); + rebuildSourceSelect($sel, $sel.data('saved-options') || $(), String($sel.data('keep-id') || $sel.data('current-source-id') || '')); + $sel.trigger('change'); + }); + mailcow_alert_box(L('source_list_error'), 'danger'); + }); + } + + function sourceOption(id, name, host, port, authType, flow) { + var sub = (host || '?') + ':' + (port || '?') + ' · ' + (authType || ''); + if (authType === 'XOAUTH2') sub += ' (' + (flow === 'authorization_code' ? L('source_flow_badge_user') : L('source_flow_badge_app')) + ')'; + return $('').val(id).text(name || ('#' + id)) + .attr('data-subtext', sub) + .attr('data-auth-type', authType || '') + .attr('data-oauth-flow', flow || ''); + } + + // Toggle password / user1 / connect rows by the selected source's auth type + function applySourceSelection($sel) { + var $opt = $sel.find('option:selected'); + var $form = $sel.closest('form'); + var isOauth = ($opt.attr('data-auth-type') === 'XOAUTH2'); + var isAuthCode = isOauth && ($opt.attr('data-oauth-flow') === 'authorization_code'); + var isEdit = ($form.data('id') === 'editsyncjob'); + var $user1 = $form.find('input[name="user1"]'); + var prevSource = $sel.data('prev-source'); + var curSource = $sel.val() || ''; + // a token belongs to (source, mailbox, remote user): switching the source drops the connect result + if (prevSource !== undefined && prevSource !== curSource && $user1.prop('readonly')) { + $user1.prop('readonly', false).val(isEdit ? $user1.data('initial') || '' : ''); + setConnectStatus($form, '', ''); + } + $sel.data('prev-source', curSource); + $form.find('.password1-row').toggle(!isOauth); + // edit: an empty password keeps the stored one + $form.find('input[name="password1"]').prop('required', !isOauth && !isEdit); + $form.find('.imapsync-oauth-connect-row').toggle(isAuthCode); + $form.find('.imapsync-user1-row').toggle(!isAuthCode); + $user1.prop('required', !isAuthCode && !isEdit); + } + + // ===== OAuth connect (authorization_code) ===== + + var oauthPending = null; + + function setConnectStatus($form, type, text, busy) { + var $status = $form.find('.imapsync-oauth-connect-status'); + $status.attr('class', 'imapsync-oauth-connect-status ms-2' + (type ? ' text-' + type : '')).empty(); + if (busy) $status.append(''); + $status.append(document.createTextNode(text || '')); + } + + function clearPending() { + if (oauthPending && oauthPending.timer) clearInterval(oauthPending.timer); + oauthPending = null; + } + + // Local mailbox the token is stored for (C1) + function connectMailbox($form) { + var $mb = $form.find('[name="username"]'); + if ($mb.length) return $mb.val() || ''; + if ($form.data('mailbox')) return String($form.data('mailbox')); + return (typeof mailcow_cc_username !== 'undefined') ? mailcow_cc_username : ''; + } + + function startConnect($form) { + var sourceId = $form.find('select.imapsync-source-select').val(); + if (!sourceId) return; + var mailbox = connectMailbox($form); + if (!mailbox) { + setConnectStatus($form, 'danger', L('oauth_select_mailbox')); + return; + } + var url = '/syncjob-oauth?action=start&source_id=' + encodeURIComponent(sourceId) + '&mailbox=' + encodeURIComponent(mailbox); + var popup = window.open(url, 'syncjob_oauth', 'width=600,height=700'); + if (!popup || popup.closed || typeof popup.closed === 'undefined') { + setConnectStatus($form, 'danger', L('oauth_popup_blocked')); + return; + } + clearPending(); + var pending = { form: $form, sourceId: String(sourceId), mailbox: mailbox.toLowerCase() }; + oauthPending = pending; + setConnectStatus($form, 'muted', L('oauth_waiting'), true); + pending.timer = setInterval(function() { + if (!popup.closed) return; + clearInterval(pending.timer); + pending.timer = null; + // stays pending: a result message that arrives after the close still wins + if (oauthPending === pending) setConnectStatus($form, 'muted', L('oauth_window_closed')); + }, 500); + } + + function onOauthMessage(ev) { + if (ev.origin !== window.location.origin || !ev.data || ev.data.type !== 'syncjob_oauth') return; + var p = oauthPending; + if (!p) return; + // only the row that started the flow is updated + if (ev.data.ok && (String(ev.data.source_id) !== p.sourceId || String(ev.data.mailbox || '').toLowerCase() !== p.mailbox)) return; + clearPending(); + if (ev.data.ok) { + p.form.find('input[name="user1"]').val(ev.data.user1).prop('readonly', true); + setConnectStatus(p.form, 'success', L('syncjob_oauth_connected') + ': ' + ev.data.user1); + } else { + var code = String(ev.data.error || '').replace(/[^a-z_]/g, ''); + setConnectStatus(p.form, 'danger', L('oauth_error_' + code) || L('oauth_error_unknown')); + } + } + + // ===== Custom params editor ===== + + // Allowlist { name: true (value) | false (flag) | 'regex' (value) }, loaded once on demand + var cpOptions = null; + var cpLoading = null; + function cpEnsureOptions(cb) { + if (cpOptions !== null) { cb(); return; } + if (!cpLoading) { + cpLoading = $.get('/api/v1/get/syncjob_options').done(function(opts) { + cpOptions = (opts && typeof opts === 'object' && !Array.isArray(opts)) ? opts : {}; + }).fail(function() { + cpLoading = null; + mailcow_alert_box(L('custom_params_options_error'), 'danger'); + }); + } + cpLoading.done(function() { cb(); }); + } + + function cpApplyValueState($row) { + var name = $row.find('.imapsync-cp-opt').val() || ''; + var isFlag = (name !== '') && !!cpOptions && cpOptions[name] === false; + var $val = $row.find('.imapsync-cp-val'); + $val.prop('disabled', isFlag); + if (isFlag) $val.val(''); + $val.prop('required', (name !== '') && !isFlag); + $val.attr('placeholder', isFlag ? L('custom_param_no_value') : L('custom_param_value')); + } + + var cpRowSeq = 0; + function cpAddRow($editor, o, v) { + var n = ++cpRowSeq; + var $row = $( + '
' + + '' + + '' + + '' + + '
' + ); + // aria-label: label elements inside .input-group break its first/last child styling + $row.find('.imapsync-cp-opt').attr('aria-label', L('custom_param_option')); + $row.find('.imapsync-cp-val').attr('aria-label', L('custom_param_value')); + $row.find('.imapsync-cp-remove').attr({ 'title': L('custom_param_remove'), 'aria-label': L('custom_param_remove') }); + var $sel = $row.find('.imapsync-cp-opt'); + $sel.append($('').attr('value', '').text('— ' + L('custom_param_option') + ' —')); + Object.keys(cpOptions || {}).sort().forEach(function(name) { + $sel.append($('').attr('value', name).text('--' + name)); + }); + // keep a stored option that is no longer allowlisted visible (the backend rejects it on save) + if (o && !(cpOptions && (o in cpOptions))) { + $sel.append($('').attr('value', o).text('--' + o)); + } + $sel.val(o || ''); + $row.find('.imapsync-cp-val').val(v || ''); + $editor.find('.imapsync-cp-rows').append($row); + cpApplyValueState($row); + } + + function cpBuild($editor) { + var $hidden = $editor.siblings('.imapsync-cp-value'); + $editor.find('.imapsync-cp-rows').empty(); + var pairs = []; + try { pairs = JSON.parse($hidden.val() || '[]'); } catch (e) { pairs = []; } + if (!Array.isArray(pairs)) pairs = []; + pairs.forEach(function(p) { + if (p && typeof p === 'object') cpAddRow($editor, p.o, p.v); + }); + } + + function cpSerialize($editor) { + var out = []; + $editor.find('.imapsync-cp-row').each(function() { + var o = $.trim($(this).find('.imapsync-cp-opt').val() || '').replace(/^-+/, ''); + if (o === '') return; + out.push({ o: o, v: $(this).find('.imapsync-cp-val').val() || '' }); + }); + $editor.siblings('.imapsync-cp-value').val(JSON.stringify(out)); + } + + function cpInit($scope) { + var $editors = $scope.find('.imapsync-cp-editor'); + if (!$editors.length) return; + cpEnsureOptions(function() { + $editors.each(function() { cpBuild($(this)); }); + }); + } + + // ===== Source editor (add modal + edit page) ===== + + function sourceFormToggle($form) { + var isOauth = $form.find('select.imapsync-source-auth-type').val() === 'XOAUTH2'; + var isAuthCode = $form.find('select.imapsync-source-oauth-flow').val() === 'authorization_code'; + var isCc = isOauth && !isAuthCode; + $form.find('.imapsync-source-oauth-block').toggle(isOauth); + $form.find('.imapsync-source-authcode-block').toggle(isOauth && isAuthCode); + $form.find('.imapsync-source-cc-block').toggle(isCc); + // client credentials sources are forced private by the backend + $form.find('.imapsync-source-scope-block').toggle(!isCc); + $form.find('.imapsync-source-scope-cc-hint').toggle(isCc); + } + + function sourceScopeToggle($form) { + var scope = $form.find('select.imapsync-source-scope').val(); + $form.find('.imapsync-source-domains-row').toggle(scope === 'domain'); + $form.find('.imapsync-source-users-row').toggle(scope === 'user'); + if (scope === 'domain') fillScopeSelect($form.find('select.imapsync-source-domains'), 'domain/all', 'domain_name'); + if (scope === 'user') fillScopeSelect($form.find('select.imapsync-source-users'), 'mailbox/all', 'username'); + } + + // Add the selectable domains/mailboxes to a scope multiselect (keeps selected options) + function fillScopeSelect($sel, url, field) { + if (!$sel.length || $sel.prop('disabled') || $sel.data('filled')) return; + $sel.data('filled', true); + $.get('/api/v1/get/' + url).done(function(rows) { + var have = {}; + $sel.find('option').each(function() { have[$(this).val()] = true; }); + $.each(Array.isArray(rows) ? rows : [], function(_, r) { + var v = r[field]; + if (!v || have[v]) return; + $sel.append($('').val(v).text(v)); + }); + // refresh duplicates preselected entries in the button label, re-init instead + if (typeof $sel.selectpicker === 'function' && $sel.data('selectpicker')) { + var keep = $sel.val(); + $sel.selectpicker('destroy'); + refreshSelect($sel); + $sel.selectpicker('val', keep); + } else { + refreshSelect($sel); + } + }).fail(function() { + $sel.data('filled', false); + mailcow_alert_box(lang_danger.access_denied, 'danger'); + }); + } + + // Extra params must be a JSON object; checked before the form is posted + function extraParamsValid($form) { + var $f = $form.find('input[name="oauth_extra_params"]'); + if (!$f.length || $f.prop('disabled') || !$f.is(':visible')) return true; + var v = $.trim($f.val() || ''); + if (v === '') { $f.removeClass('inputMissingAttr'); return true; } + var ok = false; + try { + var o = JSON.parse(v); + ok = (o !== null && typeof o === 'object' && !Array.isArray(o)); + } catch (e) { ok = false; } + $f.toggleClass('inputMissingAttr', !ok); + if (!ok) mailcow_alert_box(lang_danger.imapsync_source_oauth_extra_invalid, 'danger'); + return ok; + } + + function copyField(id) { + var el = document.getElementById(id); + if (!el) return; + var done = function() { mailcow_alert_box(L('copied'), 'success'); }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(el.value).then(done); + } else { + el.select(); + try { document.execCommand('copy'); done(); } catch (e) {} + } + } + + // ===== Wiring ===== + + function bind() { + injectStyle(); + + // custom params editor + $(document).on('change', '.imapsync-cp-opt', function() { + cpApplyValueState($(this).closest('.imapsync-cp-row')); + cpSerialize($(this).closest('.imapsync-cp-editor')); + }); + $(document).on('input', '.imapsync-cp-val', function() { + cpSerialize($(this).closest('.imapsync-cp-editor')); + }); + $(document).on('click', '.imapsync-cp-add', function() { + var $editor = $(this).closest('.imapsync-cp-editor'); + cpEnsureOptions(function() { cpAddRow($editor); }); + }); + $(document).on('click', '.imapsync-cp-remove', function() { + var $editor = $(this).closest('.imapsync-cp-editor'); + $(this).closest('.imapsync-cp-row').remove(); + cpSerialize($editor); + }); + + // job form + $(document).on('change', 'select.imapsync-source-select', function() { + applySourceSelection($(this)); + }); + // a token is bound to the local mailbox: changing it drops the connect result + $(document).on('change', 'form[data-id="add_syncjob"] select[name="username"]', function() { + var $form = $(this).closest('form'); + var $user1 = $form.find('input[name="user1"]'); + if ($user1.prop('readonly')) { + $user1.prop('readonly', false).val(''); + setConnectStatus($form, '', ''); + } + }); + $(document).on('click', '.imapsync-oauth-connect-btn', function() { + startConnect($(this).closest('form')); + }); + window.addEventListener('message', onOauthMessage); + $(document).on('click', '.imapsync-open-source-modal', function(e) { + e.preventDefault(); + var $current = $(this).closest('.modal'); + var open = function() { $('#addImapsyncSourceModal').modal('show'); }; + if ($current.length) { + $current.one('hidden.bs.modal', open).modal('hide'); + } else { + open(); + } + }); + // authorization_code: user1 comes from the provider sign-in, require it before posting + $('[data-action="add_item"][data-id="add_syncjob"], [data-action="edit_selected"][data-id="editsyncjob"]').on('click', function(e) { + var $form = $(this).closest('form'); + var $opt = $form.find('select.imapsync-source-select option:selected'); + if ($opt.attr('data-auth-type') === 'XOAUTH2' && $opt.attr('data-oauth-flow') === 'authorization_code' && + !$.trim($form.find('input[name="user1"]').val() || '')) { + e.preventDefault(); + e.stopImmediatePropagation(); + setConnectStatus($form, 'danger', lang_danger.imapsync_source_oauth_connect_required); + return false; + } + }); + + // source editor + $(document).on('change', 'select.imapsync-source-auth-type, select.imapsync-source-oauth-flow', function() { + sourceFormToggle($(this).closest('form')); + }); + $(document).on('change', 'select.imapsync-source-scope', function() { + sourceScopeToggle($(this).closest('form')); + }); + $('[data-action="add_item"][data-id="add_imapsync_source"], [data-action="edit_selected"][data-id="edit_imapsync_source"]').on('click', function(e) { + if (!extraParamsValid($(this).closest('form'))) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + }); + $(document).on('click', '.imapsync-copy-btn', function(e) { + e.preventDefault(); + copyField($(this).data('copy-target')); + }); + + // modals + $(document).on('shown.bs.modal', '#addImapsyncSourceModal', function() { + var $form = $(this).find('form'); + sourceFormToggle($form); + sourceScopeToggle($form); + }); + $(document).on('shown.bs.modal', '#addSyncJobModalAdmin, #addSyncJobModal', function() { + populateSourceSelects(); + cpInit($(this)); + }); + + // edit page: forms are already rendered + $('form[data-id="edit_imapsync_source"]').each(function() { + sourceFormToggle($(this)); + sourceScopeToggle($(this)); + }); + $('form[data-id="editsyncjob"]').each(function() { + var $form = $(this); + $form.find('input[name="user1"]').data('initial', $form.find('input[name="user1"]').val()); + populateSourceSelects(); + cpInit($form); + }); + } + + $(document).ready(bind); + + return { + drawSourceTable: drawSourceTable, + jobTableStateSave: jobTableStateSave, + jobTableStateLoad: jobTableStateLoad, + prepareJobRow: prepareJobRow, + wrapCell: wrapCell, + L: L + }; +})(jQuery); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 5ad5cceab..e073650d3 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -2169,7 +2169,11 @@ jQuery(function($){ "tr" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", language: lang_datatables, - order: [[2, 'desc']], + // keep the backend order (C4: prio desc, never run first, least recently run) + order: [], + // drop state saved before the rework (it would restore the old ID sort / column visibility) + stateSaveParams: mailcowImapsync.jobTableStateSave, + stateLoadParams: mailcowImapsync.jobTableStateLoad, initComplete: function(){ hideTableExpandCollapseBtn('#tab-syncjobs', '#sync_job_table'); }, @@ -2177,40 +2181,21 @@ jQuery(function($){ type: "GET", url: "/api/v1/get/syncjobs/all/no_log", dataSrc: function(json){ + if (!Array.isArray(json)) return []; $.each(json, function (i, item) { - item.log = '' + lang.open_logs + '' - item.user2 = escapeHtml(item.user2); if (!item.exclude > 0) { item.exclude = '-'; } else { item.exclude = '' + escapeHtml(item.exclude) + ''; } - item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.host1) + ':' + escapeHtml(item.port1); + mailcowImapsync.prepareJobRow(item); + item.user2 = mailcowImapsync.wrapCell(escapeHtml(item.user2)); item.action = ''; item.chkbox = ''; - if (item.is_running == 1) { - item.is_running = '' + lang.running + ''; - } else { - item.is_running = '' + lang.waiting + ''; - } - if (!item.last_run > 0) { - item.last_run = lang.waiting; - } - if (item.success == null) { - item.success = '-'; - item.exit_status = ''; - } else { - item.success = ''; - } - if (lang['syncjob_'+item.exit_status]) { - item.exit_status = lang['syncjob_'+item.exit_status]; - } else if (item.success != '-') { - item.exit_status = lang.syncjob_check_log; - } - item.exit_status = item.success + ' ' + item.exit_status; }); return json; @@ -2235,40 +2220,46 @@ jQuery(function($){ responsivePriority: 2 }, { - title: 'ID', + title: mailcowImapsync.L('id'), data: 'id', responsivePriority: 3, defaultContent: '' }, { - title: lang.owner, + title: lang.syncjobs.prio, + data: 'prio', + visible: role === 'admin', + responsivePriority: 3, + defaultContent: '' + }, + { + title: lang.syncjobs.user2_label, data: 'user2', responsivePriority: 4, defaultContent: '' }, { - title: 'Server', + title: lang.syncjobs.source_server, data: 'server_w_port', + responsivePriority: 6, defaultContent: '' }, { title: lang.last_run, data: 'last_run', + responsivePriority: 9, defaultContent: '' }, { - title: lang.syncjob_last_run_result, + title: lang.syncjobs.syncjob_last_run_result, data: 'exit_status', - defaultContent: '' - }, - { - title: 'Log', - data: 'log', + responsivePriority: 8, defaultContent: '' }, { title: lang.active, data: 'active', + responsivePriority: 10, defaultContent: '', render: function (data, type) { return 1==data?'1':0==data&&'0'; @@ -2277,6 +2268,7 @@ jQuery(function($){ { title: lang.status, data: 'is_running', + responsivePriority: 7, defaultContent: '' }, { @@ -2486,6 +2478,7 @@ jQuery(function($){ onVisible("[id^=alias_table]", () => draw_alias_table()); onVisible("[id^=aliasdomain_table]", () => draw_aliasdomain_table()); onVisible("[id^=sync_job_table]", () => draw_sync_job_table()); + onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1)); onVisible("[id^=filter_table]", () => draw_filter_table()); onVisible("[id^=bcc_table]", () => draw_bcc_table()); onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table()); diff --git a/data/web/js/site/user.js b/data/web/js/site/user.js index 288e7abd2..af6521f9e 100644 --- a/data/web/js/site/user.js +++ b/data/web/js/site/user.js @@ -272,22 +272,27 @@ jQuery(function($){ "tr" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", language: lang_datatables, + // keep the backend order (C4: prio desc, never run first, least recently run) + order: [], + // drop state saved before the rework (it would restore the old ID sort / column visibility) + stateSaveParams: mailcowImapsync.jobTableStateSave, + stateLoadParams: mailcowImapsync.jobTableStateLoad, ajax: { type: "GET", url: '/api/v1/get/syncjobs/' + encodeURIComponent(mailcow_cc_username) + '/no_log', dataSrc: function(data){ + if (!Array.isArray(data)) return []; $.each(data, function (i, item) { - item.user1 = escapeHtml(item.user1); - item.log = '' + lang.open_logs + '' if (!item.exclude > 0) { item.exclude = '-'; } else { item.exclude = '' + escapeHtml(item.exclude) + ''; } - item.server_w_port = escapeHtml(item.user1 + '@' + item.host1 + ':' + item.port1); + mailcowImapsync.prepareJobRow(item); if (acl_data.syncjobs === 1) { item.action = ''; item.chkbox = ''; @@ -296,26 +301,6 @@ jQuery(function($){ item.action = '-'; item.chkbox = ''; } - if (item.is_running == 1) { - item.is_running = '' + lang.running + ''; - } else { - item.is_running = '' + lang.waiting + ''; - } - if (!item.last_run > 0) { - item.last_run = lang.waiting; - } - if (item.success == null) { - item.success = '-'; - item.exit_status = ''; - } else { - item.success = ''; - } - if (lang['syncjob_'+item.exit_status]) { - item.exit_status = lang['syncjob_'+item.exit_status]; - } else if (item.success != '-') { - item.exit_status = lang.syncjob_check_log; - } - item.exit_status = item.success + ' ' + item.exit_status; }); return data; @@ -340,41 +325,34 @@ jQuery(function($){ responsivePriority: 2 }, { - title: 'ID', + title: mailcowImapsync.L('id'), data: 'id', defaultContent: '', responsivePriority: 3 }, { - title: 'Server', + title: lang.syncjobs.source_server, data: 'server_w_port', - defaultContent: '' - }, - { - title: lang.username, - data: 'user1', defaultContent: '', - responsivePriority: 3 + responsivePriority: 4 }, { title: lang.last_run, data: 'last_run', - defaultContent: '' + defaultContent: '', + responsivePriority: 7 }, { - title: lang.syncjob_last_run_result, + title: lang.syncjobs.syncjob_last_run_result, data: 'exit_status', - defaultContent: '' - }, - { - title: 'Log', - data: 'log', - defaultContent: '' + defaultContent: '', + responsivePriority: 6 }, { title: lang.active, data: 'active', defaultContent: '', + responsivePriority: 8, render: function (data, type) { return 1==data?'':0==data&&'' } @@ -385,11 +363,6 @@ jQuery(function($){ defaultContent: '', responsivePriority: 5 }, - { - title: lang.encryption, - data: 'enc1', - defaultContent: '' - }, { title: lang.excludes, data: 'exclude', @@ -699,6 +672,7 @@ jQuery(function($){ onVisible("[id^=bl_policy_mailbox_table]", () => draw_bl_policy_mailbox_table()); onVisible("[id^=wl_policy_mailbox_table]", () => draw_wl_policy_mailbox_table()); onVisible("[id^=sync_job_table]", () => draw_sync_job_table()); + onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(acl_data.syncjobs === 1)); onVisible("[id^=app_passwd_table]", () => draw_app_passwd_table()); onVisible("[id^=recent-logins]", () => last_logins('get')); }); diff --git a/data/web/json_api.php b/data/web/json_api.php index fc34b2595..71c82f1d9 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -19,7 +19,7 @@ function api_log($_data) { if ($value) { if (is_array($value)) unset($value["csrf_token"]); foreach ($value as $key => &$val) { - if(preg_match("/pass/i", $key)) { + if(preg_match("/pass|secret|oauth_extra_params/i", $key)) { $val = '*'; } } @@ -317,7 +317,10 @@ if (isset($_GET['query'])) { process_add_return(admin('add', $attr)); break; case "syncjob": - process_add_return(mailbox('add', 'syncjob', $attr)); + process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addJob($attr)); + break; + case "syncjob_source": + process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addSource($attr)); break; case "bcc": process_add_return(bcc('add', $attr)); @@ -1046,61 +1049,37 @@ if (isset($_GET['query'])) { } process_get_return($data); break; + case "syncjob_options": + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getOptions()); + break; + case "imapsync_settings": + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings()); + break; + case "syncjob_source": + switch ($object) { + case "all": + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSources()); + break; + default: + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSource($object)); + break; + } + break; case "syncjobs": switch ($object) { case "all": - $domains = mailbox('get', 'domains'); - if (!empty($domains)) { - foreach ($domains as $domain) { - $mailboxes = mailbox('get', 'mailboxes', $domain); - if (!empty($mailboxes)) { - foreach ($mailboxes as $mailbox) { - $syncjobs = mailbox('get', 'syncjobs', $mailbox); - if (!empty($syncjobs)) { - foreach ($syncjobs as $syncjob) { - if (isset($extra)) { - $details = mailbox('get', 'syncjob_details', $syncjob, explode(',', $extra)); - } - else { - $details = mailbox('get', 'syncjob_details', $syncjob); - } - if ($details) { - $data[] = $details; - } - else { - continue; - } - } - } - } - } + // Admins see every job (incl. orphaned ones), others the jobs of the mailboxes they manage + $mailboxes = array(); + if ($_SESSION['mailcow_cc_role'] != 'admin') { + foreach (mailbox('get', 'domains') ?: array() as $domain) { + $mailboxes = array_merge($mailboxes, mailbox('get', 'mailboxes', $domain) ?: array()); } - process_get_return($data); - } - else { - echo '{}'; } + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getAllJobs($extra, $mailboxes)); break; default: - $syncjobs = mailbox('get', 'syncjobs', $object); - if (!empty($syncjobs)) { - foreach ($syncjobs as $syncjob) { - if (isset($extra)) { - $details = mailbox('get', 'syncjob_details', $syncjob, explode(',', $extra)); - } - else { - $details = mailbox('get', 'syncjob_details', $syncjob); - } - if ($details) { - $data[] = $details; - } - else { - continue; - } - } - } - process_get_return($data); + process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJobs($object, $extra)); break; } break; @@ -1742,7 +1721,10 @@ if (isset($_GET['query'])) { process_delete_return(rsettings('delete', array('id' => $items))); break; case "syncjob": - process_delete_return(mailbox('delete', 'syncjob', array('id' => $items))); + process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteJobs($items)); + break; + case "syncjob_source": + process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteSources($items)); break; case "filter": process_delete_return(mailbox('delete', 'filter', array('id' => $items))); @@ -1968,7 +1950,27 @@ if (isset($_GET['query'])) { } break; case "syncjob": - process_edit_return(mailbox('edit', 'syncjob', array_merge(array('id' => $items), $attr))); + switch ($object) { + case "run_next": + process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->runNext($items)); + break; + default: + process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editJobs($items, $attr)); + break; + } + break; + case "imapsync_settings": + process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editSettings($attr)); + break; + case "syncjob_source": + switch ($object) { + case "refresh_token": + process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->refreshSourceToken($items)); + break; + default: + process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editSources($items, $attr)); + break; + } break; case "filter": process_edit_return(mailbox('edit', 'filter', array_merge(array('id' => $items), $attr))); diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 1f1c1f273..1849caf40 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -45,24 +45,16 @@ "app_name": "App name", "app_password": "Add app password", "app_passwd_protocols": "Allowed protocols for app password", - "automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)", "backup_mx_options": "Relay options", "bcc_dest_format": "BCC destination must be a single valid email address.
If you need to send a copy to multiple addresses, create an alias and use it here.", "comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview", - "custom_params": "Custom parameters", - "custom_params_hint": "Right: --param=xy, wrong: --param xy", - "delete1": "Delete from source when completed", - "delete2": "Delete messages on destination that are not on source", - "delete2duplicates": "Delete duplicates on destination", "description": "Description", "destination": "Destination", "disable_login": "Disallow login (incoming mail is still accepted)", "domain": "Domain", "domain_matches_hostname": "Domain %s matches hostname", "domain_quota_m": "Total domain quota (MiB)", - "dry": "Simulate synchronization", "enc_method": "Encryption method", - "exclude": "Exclude objects (regex)", "full_name": "Full name", "gal": "Global Address List", "gal_info": "The GAL contains all objects of a domain and cannot be edited by any user. Free/busy information in SOGo is missing, if disabled! Restart SOGo to apply changes.", @@ -81,7 +73,6 @@ "mailbox_username": "Username (left part of an email address)", "max_aliases": "Max. possible aliases", "max_mailboxes": "Max. possible mailboxes", - "mins_interval": "Polling interval (minutes)", "multiple_bookings": "Multiple bookings", "nexthop": "Next hop", "password": "Password", @@ -101,16 +92,10 @@ "select_domain": "Please select a domain first", "sieve_desc": "Short description", "sieve_type": "Filter type", - "skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)", - "subscribeall": "Subscribe all folders", - "syncjob": "Add sync job", - "syncjob_hint": "Be aware that passwords need to be saved plain-text!", "tags": "Tags", "target_address": "Goto addresses", "target_address_info": "Full email address/es (comma-separated).", "target_domain": "Target domain", - "timeout1": "Timeout for connection to remote host", - "timeout2": "Timeout for connection to local host", "username": "Username", "validate": "Validate", "validation_success": "Validated successfully" @@ -418,7 +403,12 @@ "user_quicklink": "Hide Quicklink to User Login Page", "validate_license_now": "Validate GUID against license server", "verify": "Verify", - "yes": "✓" + "yes": "✓", + "syncjobs": "Sync jobs", + "syncjob_max_parallel": "Max. parallel sync processes", + "syncjob_max_parallel_info": "How many sync jobs may run at the same time (1-32).", + "syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)", + "syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process in KB/s; a per-job limit (bytes/s) is capped by this. Total ≈ this × max parallel." }, "danger": { "access_denied": "Access denied or invalid form data", @@ -567,7 +557,30 @@ "validity_missing": "Please assign a period of validity", "value_missing": "Please provide all values", "version_invalid": "Version %s is invalid", - "yotp_verification_failed": "Yubico OTP verification failed: %s" + "yotp_verification_failed": "Yubico OTP verification failed: %s", + "imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted", + "imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed", + "imapsync_source_scope_empty": "Select at least one domain for the chosen visibility", + "imapsync_source_unavailable": "The selected sync source is unavailable", + "imapsync_source_oauth_connect_required": "Sign in with the provider before saving this sync job", + "imapsync_source_name_invalid": "Invalid sync source name", + "imapsync_source_host_invalid": "Invalid host name or IP address", + "imapsync_source_port_invalid": "Invalid port", + "imapsync_source_enc_invalid": "Invalid encryption method", + "imapsync_source_auth_invalid": "Invalid authentication method", + "imapsync_custom_param_invalid": "Custom parameters contain a disallowed imapsync option: %s", + "imapsync_source_oauth_required": "OAuth2 requires token endpoint, client ID, client secret and scope", + "imapsync_source_oauth_endpoint_invalid": "Invalid OAuth2 endpoint: an https:// URL with a valid host name is required (sources not created by an administrator need a publicly reachable host)", + "imapsync_source_oauth_extra_invalid": "OAuth2 extra parameters must be a JSON object with simple values, e.g. {\"resource\": \"https://example.com\"}", + "imapsync_source_oauth_invalid": "Invalid OAuth2 configuration", + "imapsync_max_parallel_invalid": "Parallel process count must be between 1 and 32", + "imapsync_source_description_invalid": "The description must not exceed 255 characters", + "imapsync_source_refresh_unsupported": "Manual token refresh is only available for client credentials sources", + "imapsync_custom_params_format": "Custom parameters must be sent as a list of option/value pairs", + "imapsync_custom_param_value": "Invalid value for imapsync option %s (missing value, value on a flag or disallowed value)", + "imapsync_job_running": "Sync job %s is currently running and cannot be scheduled next", + "imapsync_user1_invalid": "Invalid remote username", + "imapsync_max_kb_per_second_invalid": "Bandwidth limit must be between 0 and 10485760 KB/s" }, "datatables": { "collapse_all": "Collapse All", @@ -656,7 +669,6 @@ "app_name": "App name", "app_passwd": "App password", "app_passwd_protocols": "Allowed protocols for app password", - "automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)", "backup_mx_options": "Relay options", "bcc_dest_format": "BCC destination must be a single valid email address.
If you need to send a copy to multiple addresses, create an alias and use it here.", "client_id": "Client ID", @@ -664,9 +676,6 @@ "comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview", "created_on": "Created on", "custom_attributes": "Custom attributes", - "delete1": "Delete from source when completed", - "delete2": "Delete messages on destination that are not on source", - "delete2duplicates": "Delete duplicates on destination", "delete_ays": "Please confirm the deletion process.", "description": "Description", "disable_login": "Disallow login (incoming mail is still accepted)", @@ -690,7 +699,6 @@ "dont_check_sender_acl": "Disable sender check for domain %s (+ alias domains)", "edit_alias_domain": "Edit Alias domain", "encryption": "Encryption", - "exclude": "Exclude objects (regex)", "extended_sender_acl": "External sender addresses", "extended_sender_acl_info": "A DKIM domain key should be imported, if available.
\r\n Remember to add this server to the corresponding SPF TXT record.
\r\n Whenever a domain or alias domain is added to this server, that overlaps with an external address, the external address is removed.
\r\n Use @domain.tld to allow to send as *@domain.tld.", "force_pw_update": "Force password update at next login", @@ -721,10 +729,7 @@ "max_aliases": "Max. aliases", "max_mailboxes": "Max. possible mailboxes", "max_quota": "Max. quota per mailbox (MiB)", - "maxage": "Maximum age of messages in days that will be polled from remote
(0 = ignore age)", - "maxbytespersecond": "Max. bytes per second
(0 = unlimited)", "mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.", - "mins_interval": "Interval (min)", "mta_sts": "MTA-STS", "mta_sts_info": "MTA-STS is a standard that enforces email delivery between mail servers to use TLS with valid certificates.
It is used when DANE is not possible due to missing or unsupported DNSSEC.
Note: If the receiving domain supports DANE with DNSSEC, DANE is always preferred – MTA-STS only acts as a fallback.", "mta_sts_version": "Version", @@ -777,7 +782,6 @@ "sender_acl_info": "If mailbox user A is allowed to send as mailbox user B, the sender address is not automatically displayed as selectable \"from\" field in SOGo.
\r\n Mailbox user B needs to create a delegation in SOGo to allow mailbox user A to select their address as sender. To delegate a mailbox in SOGo, use the menu (three dots) to the right of your mailbox name in the upper left while in mail view. This behaviour does not apply to alias addresses.", "sieve_desc": "Short description", "sieve_type": "Filter type", - "skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)", "sogo_access": "Direct forwarding to SOGo", "sogo_access_info": "After logging in, the user is automatically redirected to SOGo.", "sogo_visible": "Alias is visible in SOGo", @@ -786,12 +790,8 @@ "spam_filter": "Spam filter", "spam_policy": "Add or remove items to allow-/denylist", "spam_score": "Set a custom spam score", - "subfolder2": "Sync into subfolder on destination
(empty = do not use subfolder)", - "syncjob": "Edit sync job", "target_address": "Goto address/es (comma-separated)", "target_domain": "Target domain", - "timeout1": "Timeout for connection to remote host", - "timeout2": "Timeout for connection to local host", "title": "Edit object", "unchanged_if_empty": "If unchanged leave blank", "username": "Username", @@ -1013,16 +1013,6 @@ "spam_aliases": "Temp. alias", "stats": "Statistics", "status": "Status", - "sync_jobs": "Sync jobs", - "syncjob_check_log": "Check log", - "syncjob_last_run_result": "Last run result", - "syncjob_EX_OK": "Success", - "syncjob_EXIT_CONNECTION_FAILURE": "Connection problem", - "syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection", - "syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem", - "syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota", - "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server", - "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password", "table_size": "Table size", "table_size_show_n": "Show %s items", "target_address": "Goto address", @@ -1225,7 +1215,13 @@ "verified_fido2_login": "Verified FIDO2 login", "verified_totp_login": "Verified TOTP login", "verified_webauthn_login": "Verified WebAuthn login", - "verified_yotp_login": "Verified Yubico OTP login" + "verified_yotp_login": "Verified Yubico OTP login", + "imapsync_source_added": "Sync source %s has been added", + "imapsync_source_modified": "Sync source %s has been saved", + "imapsync_source_deleted": "Sync source %s has been deleted", + "imapsync_source_token_refreshed": "OAuth token for %s has been refreshed", + "max_parallel_saved": "Sync job settings saved", + "imapsync_run_next_done": "%s sync job(s) moved to the front of the queue" }, "tfa": { "authenticators": "Authenticators", @@ -1260,6 +1256,126 @@ "waiting_usb_register": "Waiting for USB device...

Please enter your password above and confirm your registration by tapping the button on your USB device.", "yubi_otp": "Yubico OTP authentication" }, + "syncjobs": { + "sources_panel": "Sync sources", + "sources_hint": "Sync sources define IMAP servers (and their authentication method) that sync jobs can reuse. Define each source once, then pick it from the dropdown when creating sync jobs.", + "source_add": "Add source", + "source": "Source", + "source_name": "Name", + "source_description": "Description", + "source_scope": "Visibility", + "source_scope_all": "All users", + "source_scope_domain": "Specific domains", + "source_scope_user": "Specific users", + "source_domains": "Domains", + "source_users": "Users", + "source_select": "Select source", + "source_auth_type": "Authentication", + "source_oauth_block": "OAuth2 configuration", + "source_token_status": "Token valid until", + "source_token_error": "Last refresh error", + "source_refresh_now": "Refresh token now", + "source_secret_unchanged_hint": "Leave blank to keep the stored secret.", + "source_oauth_flow": "OAuth2 flow", + "source_oauth_flow_app": "App (client credentials)", + "source_oauth_flow_user": "User login (authorization code)", + "source_flow_badge_app": "Client credentials", + "source_flow_badge_user": "User login", + "token_state_issued": "Token issued", + "token_state_pending": "Token pending", + "token_state_failed": "Token failed", + "source_authorize_endpoint": "Authorize endpoint", + "source_userinfo_endpoint": "Userinfo endpoint", + "source_redirect_uri": "Redirect URI", + "source_redirect_uri_hint": "Register this URI in the provider app (e.g. Azure). Provider sign-in only works when mailcow is opened via this host name.", + "syncjob_oauth_connect": "Sign in with the provider", + "syncjob_oauth_connected": "Connected", + "sync_jobs": "Sync jobs", + "syncjob_check_log": "Check log", + "syncjob_last_run_result": "Last run result", + "syncjob_EX_OK": "Success", + "syncjob_EXIT_CONNECTION_FAILURE": "Connection problem", + "syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection", + "syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem", + "syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota", + "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server", + "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password", + "syncjob_OAUTH_TOKEN_NOT_READY": "OAuth token not ready (sign in again or wait for the token refresh)", + "add": "Add sync job", + "add_hint": "Be aware that passwords need to be saved plain-text!", + "edit": "Edit sync job", + "create": "Create new sync job", + "dry": "Simulate synchronization", + "subfolder2": "Sync into subfolder on destination
(empty = do not use subfolder)", + "mins_interval": "Polling interval (minutes)", + "maxage": "Maximum age of messages in days that will be polled from remote
(0 = ignore age)", + "exclude": "Exclude objects (regex)", + "automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)", + "delete1": "Delete from source when completed", + "delete2": "Delete messages on destination that are not on source", + "delete2duplicates": "Delete duplicates on destination", + "skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)", + "subscribeall": "Subscribe all folders", + "timeout1": "Timeout for connection to remote host", + "timeout2": "Timeout for connection to local host", + "maxbytespersecond": "Max. bandwidth in bytes/s
(0 = unlimited, additionally capped by the global limit per process in KB/s)", + "custom_params": "Custom parameters", + "custom_params_hint": "Add one imapsync option per row. Only allowlisted options are accepted; the value may contain any character (spaces, regex, …) and is passed as a single argument.", + "custom_param_option": "Option", + "custom_param_value": "Value", + "custom_param_no_value": "no value needed", + "custom_param_add": "Add parameter", + "prio": "Priority", + "prio_hint": "Jobs with a higher priority run first. Within the same priority, jobs that never ran come first, then the least recently run ones (0 = normal).", + "oauth_popup_title": "Sign in with the provider", + "oauth_popup_success": "Connected. You can close this window.", + "oauth_popup_failed": "Sign-in failed. You can close this window.", + "oauth_error_access_denied": "You are not allowed to manage sync jobs", + "oauth_error_mailbox_denied": "No access to the selected local mailbox", + "oauth_error_source_unavailable": "The sync source is unavailable or not configured for provider sign-in", + "oauth_error_provider_denied": "The provider refused the sign-in", + "oauth_error_invalid_state": "The sign-in session expired or is invalid, please try again", + "oauth_error_token_failed": "The provider did not issue a token (check client ID, secret and redirect URI)", + "oauth_error_no_identity": "The provider did not return an e-mail address for the signed-in account", + "user2_label": "Local mailbox (destination)", + "user1_label": "Remote username (source)", + "password1_label": "Remote password (source)", + "no_sources": "No sync source is available. Please add a sync source first.", + "source_list_error": "Sync sources could not be loaded", + "source_not_visible": "(not visible)", + "oauth_waiting": "Waiting for the provider…", + "oauth_popup_blocked": "The sign-in window was blocked. Please allow pop-ups for this site and try again.", + "oauth_select_mailbox": "Please select the local mailbox first", + "oauth_error_unknown": "Sign-in failed", + "oauth_hint": "The remote username is taken from the account used to sign in with the provider.", + "token_state_none": "Token pending", + "custom_params_legacy": "Legacy parameters (not migrated)", + "custom_params_legacy_hint": "These custom parameters of the original sync job could not be converted and are ignored. Add the options you still need with the parameter editor above.", + "custom_params_legacy_badge": "Legacy parameters", + "custom_param_remove": "Remove parameter", + "never_run": "Never", + "status_inactive": "Inactive", + "source_server": "Source server", + "id": "ID", + "source_created_by": "Created by", + "source_oauth_token_endpoint": "Token endpoint", + "source_oauth_client_id": "Client ID", + "source_oauth_client_secret": "Client secret", + "source_oauth_scope": "Scope", + "source_oauth_extra_params": "Extra parameters (JSON)", + "source_oauth_extra_params_hint": "Optional JSON object with additional parameters for the token request, e.g. {\"resource\": \"https://provider.example.com\"}", + "source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.", + "source_readonly_hint": "This sync source is shared with you and can only be viewed.", + "copy": "Copy", + "copied": "Copied to clipboard", + "source_view": "Show", + "custom_params_options_error": "The list of allowed imapsync options could not be loaded", + "source_missing": "No sync source is assigned to this job. Please select a source.", + "oauth_window_closed": "The sign-in window was closed before a result arrived. If you completed the sign-in, reload the page or try again.", + "custom_params_legacy_badge_title": "Legacy custom parameters that could not be converted are ignored. Open the edit page for details.", + "status_waiting": "Waiting", + "status_running": "Running" + }, "user": { "action": "Action", "active": "Active", @@ -1290,7 +1406,6 @@ "clear_recent_successful_connections": "Clear seen successful connections", "client_configuration": "Show configuration guides for email clients and smartphones", "create_app_passwd": "Create app password", - "create_syncjob": "Create new sync job", "created_on": "Created on", "daily": "Daily", "day": "day", @@ -1400,16 +1515,6 @@ "spamfilter_wl_desc": "Allowlisted email addresses are programmed to never classify as spam. Wildcards may be used. A filter is only applied to direct aliases (aliases with a single target mailbox) excluding catch-all aliases and a mailbox itself.", "spamfilter_yellow": "Yellow: this message may be spam, will be tagged as spam and moved to your junk folder", "status": "Status", - "sync_jobs": "Sync jobs", - "syncjob_check_log": "Check log", - "syncjob_last_run_result": "Last run result", - "syncjob_EX_OK": "Success", - "syncjob_EXIT_CONNECTION_FAILURE": "Connection problem", - "syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection", - "syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem", - "syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota", - "syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server", - "syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password", "tag_handling": "Set handling for tagged mail", "tag_help_example": "Example for a tagged email address: me+Facebook@example.org", "tag_help_explain": "In subfolder: a new subfolder named after the tag will be created below INBOX (\"INBOX/Facebook\").
\r\nIn subject: the tags name will be prepended to the mails subject, example: \"[Facebook] My News\".", diff --git a/data/web/syncjob-oauth.php b/data/web/syncjob-oauth.php new file mode 100644 index 000000000..1046dc97a --- /dev/null +++ b/data/web/syncjob-oauth.php @@ -0,0 +1,103 @@ + redirect the logged-in user to the provider consent screen +// (callback, ?code&state) -> exchange code, store the token for mailbox M, message the opener +// The token is bound to the local mailbox M (owner) and the authenticated remote identity (syncjob user1). +// The redirect URI is always https://MAILCOW_HOSTNAME/syncjob-oauth, so the UI must be used on that host. +// postMessage only carries short error codes, the UI translates them: +// access_denied, mailbox_denied, source_unavailable, provider_denied, invalid_state, token_failed, no_identity +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; + +function syncjob_oauth_finish($ok, $payload) { + // Renders a tiny page that hands the result back to the opener modal and closes the popup. + global $lang; + header('Content-Type: text/html; charset=utf-8'); + header('Cache-Control: no-store'); + $json = json_encode(array_merge(array('type' => 'syncjob_oauth', 'ok' => $ok), $payload), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); + $title = $lang['syncjobs']['oauth_popup_title'] ?? 'Sign in with the provider'; + $text = $ok + ? ($lang['syncjobs']['oauth_popup_success'] ?? 'Connected. You can close this window.') + : ($lang['syncjobs']['oauth_popup_failed'] ?? 'Authentication failed. You can close this window.'); + echo '' . htmlspecialchars($title) . '' + . '

' . htmlspecialchars($text) . '

'; + exit; +} + +// Must be an authenticated session with the syncjobs ACL (user_acl / da_acl; always set for admins). +if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'])) { + header('Location: /'); + exit; +} +$context = \Mailcow\Core\Context::fromSession($_SESSION); +$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context); +if (!$context->hasAcl('syncjobs')) { + syncjob_oauth_finish(false, array('error' => 'access_denied')); +} + +$action = isset($_GET['action']) ? $_GET['action'] : ((isset($_GET['code']) || isset($_GET['error'])) ? 'callback' : ''); + +if ($action == 'start') { + $source_id = intval($_GET['source_id'] ?? 0); + // C1: the token is stored for this local mailbox; default is the own mailbox (role user) + $mailbox = (isset($_GET['mailbox']) && is_string($_GET['mailbox']) && $_GET['mailbox'] !== '') + ? strtolower(trim($_GET['mailbox'])) : $_SESSION['mailcow_cc_username']; + if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL) || !$context->canAccessMailbox($mailbox)) { + syncjob_oauth_finish(false, array('error' => 'mailbox_denied')); + } + // findConnectable enforces visibility for the current session + $state = bin2hex(random_bytes(16)); + $source = $imapsync->sources->findConnectable($source_id); + $url = $source ? $imapsync->oauth->authorizeUrl($source, $state) : null; + if ($url === null) { + syncjob_oauth_finish(false, array('error' => 'source_unavailable')); + } + $_SESSION['syncjob_oauth2state'] = $state; + $_SESSION['syncjob_oauth_source_id'] = $source_id; + $_SESSION['syncjob_oauth_mailbox'] = $mailbox; + header('Location: ' . $url); + exit; +} + +// --- callback --- +// CSRF: state must match the one stored at 'start' +$state_ok = !empty($_GET['state']) && is_string($_GET['state']) + && hash_equals((string)($_SESSION['syncjob_oauth2state'] ?? ''), $_GET['state']); +$source_id = intval($_SESSION['syncjob_oauth_source_id'] ?? 0); +$mailbox = (string)($_SESSION['syncjob_oauth_mailbox'] ?? ''); +if ($state_ok) { + unset($_SESSION['syncjob_oauth2state'], $_SESSION['syncjob_oauth_source_id'], $_SESSION['syncjob_oauth_mailbox']); +} +if (isset($_GET['error'])) { + // Provider error codes are not passed through (only a fixed code) + syncjob_oauth_finish(false, array('error' => 'provider_denied')); +} +if (!$state_ok || empty($_GET['code']) || !is_string($_GET['code']) || $source_id <= 0 || $mailbox === '') { + syncjob_oauth_finish(false, array('error' => 'invalid_state')); +} +// Re-check access to the owner mailbox (session could have changed in between) +if (!$context->canAccessMailbox($mailbox)) { + syncjob_oauth_finish(false, array('error' => 'mailbox_denied')); +} + +$source = $imapsync->sources->findConnectable($source_id); +if (!$source) { + syncjob_oauth_finish(false, array('error' => 'source_unavailable')); +} + +// C1: the token is bound to the local owner mailbox and the remote identity (id_token claims, else userinfo). +// Sanitized details go to the php-fpm log only, the opener gets a fixed code. +try { + $user1 = $imapsync->oauth->connectUser($source, $mailbox, $_GET['code']); +} +catch (\Mailcow\Core\UserError $e) { + if ($e->args) { + error_log('syncjob-oauth: ' . $e->args[0]); + } + syncjob_oauth_finish(false, array('error' => $e->key)); +} +syncjob_oauth_finish(true, array('source_id' => $source_id, 'mailbox' => $mailbox, 'user1' => $user1)); diff --git a/data/web/templates/admin.twig b/data/web/templates/admin.twig index 651107ccf..0c487c4ea 100644 --- a/data/web/templates/admin.twig +++ b/data/web/templates/admin.twig @@ -22,6 +22,7 @@
  • +
  • @@ -51,6 +52,7 @@ {% include 'admin/tab-config-f2b.twig' %} {% include 'admin/tab-config-quarantine.twig' %} {% include 'admin/tab-config-quota.twig' %} + {% include 'admin/tab-config-syncjob.twig' %} {% include 'admin/tab-config-rsettings.twig' %} {% include 'admin/tab-config-customize.twig' %} {% include 'admin/tab-config-password-settings.twig' %} diff --git a/data/web/templates/admin/tab-config-syncjob.twig b/data/web/templates/admin/tab-config-syncjob.twig new file mode 100644 index 000000000..21479d48e --- /dev/null +++ b/data/web/templates/admin/tab-config-syncjob.twig @@ -0,0 +1,35 @@ +
    +
    +
    + + {{ lang.admin.syncjobs }} +
    +
    +
    +
    +
    + + + {{ lang.admin.syncjob_max_parallel_info }} +
    +
    + + + {{ lang.admin.syncjob_max_kb_per_second_info }} +
    +
    + +
    +
    +
    +
    diff --git a/data/web/templates/edit.twig b/data/web/templates/edit.twig index 375018c66..a5c2cc154 100644 --- a/data/web/templates/edit.twig +++ b/data/web/templates/edit.twig @@ -26,6 +26,7 @@ var lang_user = {{ lang_user|raw }}; var lang_admin = {{ lang_admin|raw }}; var lang_datatables = {{ lang_datatables|raw }}; + var lang_syncjobs = {{ lang_syncjobs|raw }}; var csrf_token = '{{ csrf_token }}'; var pagination_size = Math.trunc('{{ pagination_size }}'); var table_for_domain = '{{ domain }}'; diff --git a/data/web/templates/edit/imapsync_source.twig b/data/web/templates/edit/imapsync_source.twig new file mode 100644 index 000000000..d6ad9d601 --- /dev/null +++ b/data/web/templates/edit/imapsync_source.twig @@ -0,0 +1,233 @@ +{% extends 'edit.twig' %} + +{% block inner_content %} +{% if result %} + {# XOAUTH2 sources are managed by admins and domain admins only #} + {% set ro = not result.can_edit or (mailcow_cc_role == 'user' and result.auth_type == 'XOAUTH2') %} + {% set show_oauth = mailcow_cc_role != 'user' or result.auth_type == 'XOAUTH2' %} + {% set is_cc = result.auth_type == 'XOAUTH2' and result.oauth_flow != 'authorization_code' %} +

    {{ lang.syncjobs.source }}: {{ result.name }}

    + {% if ro %} + + {% endif %} +
    + +
    + +
    + +
    +
    +
    + +
    + +
    +
    + {% if result.created_by %} +
    + +
    + +
    +
    + {% endif %} + {% if (mailcow_cc_role == 'admin' or mailcow_cc_role == 'domainadmin') and not ro %} + + + {% else %} +
    + +
    + {% set scope_targets = result.scope == 'domain' ? result.domains : (result.scope == 'user' ? result.users : []) %} + +
    +
    + {% endif %} +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + {% if show_oauth %} +
    + {{ lang.syncjobs.source_oauth_block }} +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    + + +
    + {% if not ro %} + {{ lang.syncjobs.source_redirect_uri_hint }} + {% endif %} +
    +
    +
    +
    + +
    + +
    +
    + {% if not ro %} +
    + +
    + + {{ lang.syncjobs.source_secret_unchanged_hint }} +
    +
    + {% endif %} +
    + +
    + +
    +
    + {% if not ro %} +
    + +
    + + {{ lang.syncjobs.source_oauth_extra_params_hint }} +
    +
    + {% endif %} + {% if is_cc %} + {# token state of the stored client credentials token (authorization_code tokens live per sync job) #} +
    +
    +

    + {% if result.oauth_last_refresh_error %} + {{ lang.syncjobs.token_state_failed }} + {% elseif result.has_access_token %} + {{ lang.syncjobs.token_state_issued }} + {% if result.oauth_token_expires %}{{ lang.syncjobs.source_token_status }}: {{ result.oauth_token_expires|date('Y-m-d H:i:s') }}{% endif %} + {% else %} + {{ lang.syncjobs.token_state_none }} + {% endif %} +

    + {% if result.oauth_last_refresh_error %} +

    {{ lang.syncjobs.source_token_error }}: {{ result.oauth_last_refresh_error }}

    + {% endif %} + {% if not ro %} + {# own data-id: must not harvest/post the form fields #} + + {% endif %} +
    +
    + {% endif %} +
    + {% endif %} +
    +
    +
    + +
    +
    +
    + {% if not ro %} +
    +
    + +
    +
    + {% endif %} +
    +{% else %} + {{ parent() }} +{% endif %} +{% endblock %} diff --git a/data/web/templates/edit/syncjob.twig b/data/web/templates/edit/syncjob.twig index 801009849..8b958079b 100644 --- a/data/web/templates/edit/syncjob.twig +++ b/data/web/templates/edit/syncjob.twig @@ -2,8 +2,8 @@ {% block inner_content %} {% if result %} -

    {{ lang.edit.syncjob }}

    -
    +

    {{ lang.syncjobs.edit }}

    + @@ -13,139 +13,180 @@
    - +
    - +
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - + {% if result.source_id %} + + {% endif %} + {% if not result.source_id %} + + {% endif %} +
    {{ lang.syncjobs.no_sources }}
    +
    +
    +
    + +
    + +
    +
    +
    +
    + + {% if result.user_token_exists == 1 and not result.user_token_error %} + {{ lang.syncjobs.syncjob_oauth_connected }}: {{ result.user1 }} + {% elseif result.user_token_error %} + {{ lang.syncjobs.token_state_failed }}: {{ result.user1 }} + {% else %} + {{ lang.syncjobs.token_state_pending }} + {% endif %} +
    {{ lang.syncjobs.oauth_hint }}
    +
    +
    +
    + +
    + + {{ lang.edit.unchanged_if_empty }}
    - +
    - + 1-43800
    + {% if mailcow_cc_role == 'admin' %}
    - + +
    + + {{ lang.syncjobs.prio_hint }} +
    +
    + {% endif %} +
    +
    - +
    0-32000
    - +
    0-125000000
    - +
    1-32000
    - +
    1-32000
    - +
    - +
    - - {{ lang.add.custom_params_hint }} +
    +
    + +
    + + {{ lang.syncjobs.custom_params_hint }}
    + {% if result.custom_params_legacy %} +
    + +
    +
    {{ lang.syncjobs.custom_params_legacy_hint }}
    + +
    +
    + {% endif %}
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    diff --git a/data/web/templates/mailbox.twig b/data/web/templates/mailbox.twig index 1265d00cc..f330a76b9 100644 --- a/data/web/templates/mailbox.twig +++ b/data/web/templates/mailbox.twig @@ -33,7 +33,7 @@
  • - + @@ -67,6 +67,7 @@