[Web] Add Mailcow\ class structure and imapsync module classes

This commit is contained in:
FreddleSpl0it committed 2026-10-22 10:07:43 +02:00
1 parent 036607b4bf
commit b2a152fe45
26 files changed
+2821

No files matched your search

+5
View File
@@ -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/"
}
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* The caller may not do this (or may not see the object). Always shown as `access_denied`.
*/
class AccessDenied extends UserError
{
public function __construct()
{
parent::__construct('access_denied');
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Collects the messages of one API call and writes them to $_SESSION['return'] in the mailcow format
* (`type`, `log`, `msg`), which json_api.php and the UI read. This is the only class that touches the session.
*
* Values of the configured secret keys are replaced by '*' inside every array of the `log` part,
* so passwords or client secrets never reach the mailcow log or an API response.
*/
class ApiResponse
{
private array $messages = [];
public function __construct(private array $secretKeys = [])
{
}
/** Returns true, so callers can `return $response->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];
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Who is calling: role, username, ACL flags and the access-all exception of the session.
*
* Services get a Context instead of reading $_SESSION. Build it once per request in the entry point:
* `Context::fromSession($_SESSION)` for web requests, `Context::system()` for cron/CLI code paths
* that do no permission checks.
*/
class Context
{
/**
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
*/
public function __construct(
public readonly string $role,
public readonly ?string $username,
private readonly array $acl = [],
public readonly bool $accessAll = false
) {
}
public static function fromSession(array $session): self
{
$username = $session['mailcow_cc_username'] ?? null;
return new self(
(string)($session['mailcow_cc_role'] ?? ''),
is_scalar($username) ? (string)$username : null,
is_array($session['acl'] ?? null) ? $session['acl'] : [],
($session['access_all_exception'] ?? null) == '1'
);
}
/** Caller for cron and CLI code: not a logged-in account, so every permission check fails */
public static function system(): self
{
return new self('system', null);
}
public function isAdmin(): bool
{
return $this->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);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Helpers for loosely typed request values. Form values arrive as strings, JSON bodies as any type,
* and 011-api.js turns JSON-looking strings into arrays, so services must not trust the PHP type.
*/
class Input
{
/**
* Scalar value as trimmed string; '' for null; null for arrays/objects (= invalid).
*/
public static function text(mixed $value): ?string
{
if ($value === null) {
return '';
}
if (!is_scalar($value)) {
return null;
}
return trim((string)$value);
}
/**
* Integer within [$min, $max] (filter_var rules: "12" and 12 pass, "12abc" and "1.5" do not), else null.
*/
public static function intInRange(mixed $value, int $min, int $max): ?int
{
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]);
return ($int === false) ? null : $int;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
use PDO;
use PDOStatement;
use Throwable;
/**
* Base class of the repositories: the PDO plus the helpers every repository needs.
* Named parameters are passed without the colon (['id' => 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;
}
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
use RuntimeException;
/**
* An expected error that is shown to the user (validation failed, not found, not allowed).
*
* Carries a lang key of the `danger` section plus optional message arguments, e.g.
* `throw new UserError('object_exists', [$name]);`
* Controllers turn it into an ApiResponse message; anything else that is thrown is a real failure.
*/
class UserError extends RuntimeException
{
public function __construct(public readonly string $key, public readonly array $args = [])
{
parent::__construct($key);
}
/** The `msg` value of a mailcow message: the lang key alone, or [key, arg1, ...] when there are arguments */
public function msg(): string|array
{
return empty($this->args) ? $this->key : array_merge([$this->key], array_values($this->args));
}
}
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Controller;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Service\JobService;
use Mailcow\Imapsync\Service\SourceService;
use PDOException;
/**
* HTTP API adapter for json_api.php (and the pages that show the same data).
*
* Write methods take the decoded request ($items = ids, $attr = attributes), loop over batch ids, turn
* UserError into danger messages and write all messages to $_SESSION['return'] via ApiResponse.
* They return what json_api passes to process_*_return(): false when the whole call was rejected.
* A database error stops the call with one `mysql_error` message, like the rest of mailcow.
* Read methods return the API data (an empty array when there is nothing the caller may see).
* Messages are logged as ['syncjob', action, type, data] like before the conversion.
*/
class ImapsyncApiController
{
public function __construct(private SourceService $sources, private JobService $jobs, private ApiResponse $response)
{
}
/** POST add/syncjob */
public function addJob(array $attr): bool
{
return $this->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/<id>, 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/<mailbox>; 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();
}
}
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Http;
/**
* HTTPS requests to OAuth provider endpoints with an address policy against SSRF (C5).
*
* Every endpoint must be https://, without userinfo, with a valid hostname. In strict mode (endpoints of
* sources not created by an admin) every resolved address must be public; curl is then pinned to the checked
* addresses (CURLOPT_RESOLVE), so a DNS change between check and request does not help an attacker.
*
* resolve() and send() are the only I/O and protected, so tests can replace them.
*/
class OAuthClient
{
private const MAX_URL_LENGTH = 500;
private const MAX_ERROR_LENGTH = 200;
public function isAllowed(string $url, bool $strict): bool
{
return $this->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 <code>" 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);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Http;
use RuntimeException;
/**
* A failed OAuth request. The message is safe to store and show: "HTTP <code>" plus the provider's
* error fields, or a fixed text; never a raw response body or curl details.
*/
class OAuthException extends RuntimeException
{
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Imapsync\Controller\ImapsyncApiController;
use Mailcow\Imapsync\Http\OAuthClient;
use Mailcow\Imapsync\Model\CustomParams;
use Mailcow\Imapsync\Repository\JobRepository;
use Mailcow\Imapsync\Repository\SettingsRepository;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
use Mailcow\Imapsync\Service\JobService;
use Mailcow\Imapsync\Service\OAuthService;
use Mailcow\Imapsync\Service\SourceService;
use PDO;
/**
* Composition root of the sync job module: wires repositories, services and the API controller.
*
* $imapsync = ImapsyncModule::create($pdo, Context::fromSession($_SESSION), [
* 'allowlist' => 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'),
]);
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
use Mailcow\Core\UserError;
/**
* Rules for the extra imapsync options of a sync job: a list of {o: option, v: value} pairs, stored as JSON
* (`imapsync`.`custom_params`, read by the runner). Stored values are passed through unchanged.
*
* Allowlist rules (option name lowercase, without dashes):
* true option requires a non-empty value
* false flag, must not have a value
* string PCRE (no delimiters) the whole non-empty value must match
*/
class CustomParams
{
private const MAX_PAIRS = 100;
private const MAX_VALUE_LENGTH = 1000;
private static bool $warnedOldFormat = false;
/**
* Validates request input (list of {o, v}) against the allowlist and returns the JSON to store.
* Empty input means no options.
*
* @throws UserError imapsync_custom_params_format, imapsync_custom_param_invalid, imapsync_custom_param_value
*/
public static function toJson(mixed $input, array $allowlist): string
{
$input = ($input === null || $input === '') ? [] : $input;
if (!is_array($input)) {
throw new UserError('imapsync_custom_params_format');
}
$pairs = [];
foreach ($input as $pair) {
$option = is_array($pair) ? ($pair['o'] ?? '') : null;
$value = is_array($pair) ? ($pair['v'] ?? '') : null;
if (!is_scalar($option) || !is_scalar($value)) {
throw new UserError('imapsync_custom_params_format');
}
$option = ltrim(strtolower(trim((string)$option)), '-');
$value = str_replace("\0", '', (string)$value);
if ($option === '' && trim($value) === '') {
continue; // empty UI row
}
$value = self::checkValue($option, $value, $allowlist);
if (strlen($value) > 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;
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* A sync job (table `imapsync`): copies the remote account user1 of a source into the local mailbox user2.
*
* Loaded jobs also carry display data of their source and of the per-user token ($source / $token,
* filled from the JOIN; no secrets). Switches default to off, because unchecked form checkboxes are not sent.
*/
class Job
{
public ?int $id = null;
public string $user2 = '';
public ?string $regextrans2 = '';
public int $authmd51 = 0;
public string $domain2 = '';
public string $subfolder2 = '';
public string $user1 = '';
/** Remote password, never returned by toArray() */
public string $password1 = '';
public string $exclude = '';
public int $maxage = 0;
public int $minsInterval = 0;
/** Bytes/s limit, stored as text */
public string $maxBytesPerSecond = '0';
public int $delete2duplicates = 0;
public int $delete1 = 0;
public int $delete2 = 0;
public int $automap = 0;
public int $skipcrossduplicates = 0;
/** JSON list of {o, v} pairs (see CustomParams), old rows may hold other text */
public ?string $customParams = '[]';
public ?string $customParamsLegacy = null;
public int $timeout1 = 600;
public int $timeout2 = 600;
public int $subscribeall = 0;
public int $dry = 0;
public int $isRunning = 0;
public ?string $returnedText = null;
public ?string $lastRun = null;
public ?int $success = null;
public ?string $exitStatus = null;
public ?string $created = null;
public ?string $modified = null;
public int $active = 0;
public int $sourceId = 0;
public int $prio = 0;
public ?Source $source = null;
public ?OAuthToken $token = null;
/**
* Builds a job from an `imapsync` row, optionally joined with `source_<column>` (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 : '',
];
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* Per-user OAuth token of an authorization_code source (table `imapsync_source_oauth_token`).
*
* Bound to (source, owner, username): owner = local mailcow mailbox (job user2),
* username = authenticated remote identity (job user1).
*/
class OAuthToken
{
public int $sourceId = 0;
public string $owner = '';
public string $username = '';
public ?string $accessToken = null;
public ?string $refreshToken = null;
public ?int $tokenExpires = null;
public ?string $lastRefreshError = null;
/** Seconds since the last refresh attempt by the DB clock; only filled by the refresh queries */
public ?int $refreshAttemptAge = null;
public static function fromRow(array $row): self
{
$token = new self();
$token->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;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* Global sync job settings (table `imapsync_settings`), read by the runner.
*/
class Settings
{
public int $maxParallel = 1;
/** Global bandwidth limit in bytes/s, 0 = unlimited */
public int $maxBytesPerSecond = 0;
/**
* API representation (GET imapsync_settings); the UI edits the limit in KB/s.
*/
public function toArray(): array
{
return [
'max_parallel' => $this->maxParallel,
'max_bytes_per_second' => $this->maxBytesPerSecond,
'max_kb_per_second' => intdiv($this->maxBytesPerSecond, 1024),
];
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* A reusable IMAP source (table `imapsync_source` plus its scope targets).
*
* Visibility is driven by `scope` and the target lists:
* 'all' visible to everyone (admin-created only)
* 'domain' visible to the listed domains (their domain admins and users)
* 'user' visible to the listed users (and their domain admins); no targets = private
* `createdBy` records the creator ('' = handed over to the admins).
*
* The object holds secrets (client secret, access token); toArray() never returns them.
*/
class Source
{
public const AUTH_XOAUTH2 = 'XOAUTH2';
public const FLOW_CLIENT_CREDENTIALS = 'client_credentials';
public const FLOW_AUTHORIZATION_CODE = 'authorization_code';
public ?int $id = null;
public string $name = '';
public string $description = '';
public string $createdBy = '';
public string $scope = 'user';
public string $host = '';
public int $port = 0;
public ?string $enc = 'TLS';
public ?string $authType = 'PLAIN';
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
public ?string $tokenEndpoint = null;
public ?string $authorizeEndpoint = null;
public ?string $userinfoEndpoint = null;
public ?string $clientId = null;
public ?string $clientSecret = null;
public ?string $oauthScope = null;
/** Extra token request parameters, JSON object text */
public ?string $extraParams = null;
/** Shared token of a client_credentials source */
public ?string $accessToken = null;
public ?int $tokenExpires = null;
public ?string $lastRefreshError = null;
public ?string $lastRefreshAttempt = null;
/** Seconds since lastRefreshAttempt by the DB clock; only filled by the refresh queries */
public ?int $refreshAttemptAge = null;
public ?string $created = null;
public ?string $modified = null;
public int $active = 1;
/** @var string[] */
public array $domains = [];
/** @var string[] */
public array $users = [];
/** Builds a source from an `imapsync_source` row (column names as in the table) */
public static function fromRow(array $row): self
{
$source = new self();
$source->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;
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Job;
use PDO;
/**
* SQL for sync jobs (table `imapsync`).
*/
class JobRepository extends Repository
{
/** Job queue order (C4): prio desc, never-run first, then least recently run */
private const QUEUE_ORDER = "ORDER BY `prio` DESC, `last_run` IS NOT NULL, `last_run` ASC, `id` ASC";
/** Every column except returned_text (the log, loaded on demand) */
private const COLUMNS = ['id', 'user2', 'regextrans2', 'authmd51', 'domain2', 'subfolder2', 'user1', 'password1',
'exclude', 'maxage', 'mins_interval', 'maxbytespersecond', 'delete2duplicates', 'delete1', 'delete2', 'automap',
'skipcrossduplicates', 'custom_params', 'timeout1', 'timeout2', 'subscribeall', 'dry', 'is_running',
'last_run', 'success', 'exit_status', 'created', 'modified', 'active', 'source_id', 'custom_params_legacy', 'prio'];
/**
* The job with display data of its source and per-user token (C1: source, owner = user2, username = user1).
* $withLog also loads returned_text.
*/
public function find(int $id, bool $withLog = true): ?Job
{
$columns = $withLog ? [...self::COLUMNS, 'returned_text'] : self::COLUMNS;
$row = $this->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), '?'));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Settings;
use PDO;
/**
* SQL for the global sync job settings (table `imapsync_settings`, name => 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]);
}
}
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for `imapsync_source` and its target tables `imapsync_source_domain` / `imapsync_source_user`.
* No permission decisions here except the visibility filter, which is pure SQL (see visibility()).
*/
class SourceRepository extends Repository
{
/** Full source incl. secrets and targets, without visibility filter */
public function find(int $id): ?Source
{
return $this->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,
];
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for OAuth tokens: the shared token of a client_credentials source (columns of `imapsync_source`)
* and the per-user tokens of authorization_code sources (`imapsync_source_oauth_token`).
*/
class TokenRepository extends Repository
{
private const USER_TOKEN = "`source_id` = :sid AND `owner` = :owner AND `username` = :user";
public function exists(int $sourceId, string $owner, string $username): bool
{
return (bool)$this->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];
}
}
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Model\CustomParams;
use Mailcow\Imapsync\Model\Job;
use Mailcow\Imapsync\Model\Settings;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\JobRepository;
use Mailcow\Imapsync\Repository\SettingsRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
/**
* Use cases and rules for sync jobs and the global sync settings.
*
* A job belongs to its local mailbox (user2): whoever may access that mailbox may see and manage it
* (admins also orphaned jobs). Writing needs the `syncjobs` ACL. Jobs on an authorization_code source
* need the per-user token of (source, owner = user2, username = user1) first (C1).
*/
class JobService
{
private const FLAGS = ['active', 'delete2duplicates', 'subscribeall', 'dry', 'delete1', 'delete2', 'automap', 'skipcrossduplicates'];
public function __construct(private JobRepository $jobs, private SourceService $sources, private TokenRepository $tokens,
private SettingsRepository $settings, private Context $context, private array $allowlist)
{
}
/** @throws AccessDenied without the syncjobs ACL */
public function requireAcl(): void
{
if (!$this->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 '';
}
}
@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Exception;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Http\OAuthClient;
use Mailcow\Imapsync\Http\OAuthException;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
use PDOException;
/**
* OAuth2 for XOAUTH2 sources: their OAuth settings, token requests for every grant type, the browser sign-in
* (authorization_code) and token refresh. Needs no Context, so the refresh cron can use it; callers check
* permissions first.
*/
class OAuthService
{
/** Tokens expiring within this many seconds are refreshed */
private const REFRESH_AHEAD = 300;
/** A token whose last refresh failed is retried after this many seconds */
private const RETRY_AFTER = 900;
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
private const UNUSED_TOKEN_GRACE = 86400;
private string $hostname;
public function __construct(private SourceRepository $sources, private TokenRepository $tokens, private OAuthClient $client,
string $hostname)
{
$this->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<int, array{priority: string, message: string}> 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;
}
}
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
/**
* Use cases and rules for sync sources.
*
* Read: admin all; domain admin own + 'all' + sources targeting his domains/mailboxes + sources created by
* his mailboxes; user own + 'all' + sources targeting him or his domain (see SourceRepository).
* Edit: admin all; creator own; domain admin sources created by his mailboxes.
* XOAUTH2 sources are created and edited by admins and domain admins only (users may use them).
* Users and domain admins need the `syncjobs` ACL for everything.
*/
class SourceService
{
private const ENCRYPTIONS = ['TLS', 'SSL', 'PLAIN'];
private const AUTH_TYPES = ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'];
private const SCOPES = ['all', 'domain', 'user'];
public function __construct(private SourceRepository $repo, private TokenRepository $tokens, private OAuthService $oauth,
private Context $context)
{
}
/** @throws AccessDenied without the syncjobs ACL */
public function requireAcl(): void
{
if (!$this->context->hasAcl('syncjobs')) {
throw new AccessDenied();
}
}
/** Add/edit: a logged-in admin, domain admin or user with the syncjobs ACL */
public function requireEditor(): void
{
if (!($this->context->isAdmin() || $this->context->isDomainAdmin() || $this->context->isUser())) {
throw new AccessDenied();
}
$this->requireAcl();
}
/**
* The source when the caller may see it (incl. secrets: never pass the object to the UI, use toArray()).
* Users and domain admins without the syncjobs ACL see no sources.
*/
public function get(int $id): ?Source
{
return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null;
}
/** @return int[] ids of the sources the caller may see, ordered by name */
public function visibleIds(): array
{
return $this->canRead() ? $this->repo->visibleIds($this->context) : [];
}
public function canEdit(Source $source): bool
{
if ($this->context->accessAll || $this->context->isAdmin() || $source->createdBy === $this->context->username) {
return true;
}
// A domain admin may manage sources created by mailboxes of his domains
return $this->context->isDomainAdmin() && $source->createdBy !== '' && $this->context->canAccessMailbox($source->createdBy);
}
/** API representation for the caller (can_edit, extra params only for editors, redirect URI) */
public function toArray(Source $source): array
{
return $source->toArray($this->canEdit($source), $this->oauth->redirectUri());
}
/**
* Creates a source from request attributes (C9 names; host1/port1/enc1 are accepted as aliases).
*
* @throws UserError
*/
public function create(array $input): Source
{
$this->requireEditor();
$source = new Source();
$source->createdBy = (string)$this->context->username;
$this->applyBasics($source, $input);
$this->applyOAuth($source, $input);
// Scope and targets are validated before anything is written
$this->applyScope($source, $input['scope'] ?? 'user', $input['domains'] ?? [], $input['users'] ?? []);
if ($this->repo->nameExists($source->name, $source->createdBy)) {
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
}
$this->repo->insert($source);
return $source;
}
/**
* Updates a source; attributes missing in $input keep their value.
*
* @throws UserError
*/
public function update(int $id, array $input): Source
{
$this->requireEditor();
$current = $this->editable($id);
$source = clone $current;
$this->applyBasics($source, $input);
if ($source->name !== $current->name && $this->repo->nameExists($source->name, $source->createdBy, $id)) {
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
}
$this->applyOAuth($source, $input);
// Targets only change when the request carries `scope`; client_credentials sources are always private
$writeTargets = isset($input['scope']) || $source->usesClientCredentials();
if ($writeTargets) {
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
}
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
return $source;
}
/**
* Deletes an unused source and returns it.
*
* @throws UserError access_denied, imapsync_source_in_use
*/
public function delete(int $id): Source
{
$this->requireAcl();
$source = $this->editable($id);
// FK ON DELETE RESTRICT prevents removal while sync jobs reference it
if ($this->repo->isInUse($id)) {
throw new UserError('imapsync_source_in_use', [$source->name]);
}
$this->repo->delete($id);
return $source;
}
/**
* Fetches a new shared token for a client_credentials source now.
*
* @throws UserError access_denied, imapsync_source_refresh_unsupported, imapsync_source_token_refresh_failed
*/
public function refreshToken(int $id): Source
{
$this->requireAcl();
$source = $this->editable($id);
if (!$source->usesClientCredentials()) {
throw new UserError('imapsync_source_refresh_unsupported');
}
$error = $this->oauth->refreshSourceToken($source);
if ($error !== null) {
throw new UserError('imapsync_source_token_refresh_failed', [$source->name . ' (' . $error . ')']);
}
return $source;
}
/** A visible, active authorization_code source the caller may sign in to (OAuth popup), else null */
public function findConnectable(int $id): ?Source
{
$source = $this->get($id);
return ($source !== null && $source->active === 1 && $source->usesAuthorizationCode()) ? $source : null;
}
/**
* Account rename (mailbox, admin, domain admin): sources, user targets and token owners follow.
* Leftover sources of an earlier account named $new are released first (see releaseSourcesOf()).
* Runs on the caller's PDO; a PDOException is not caught, so a caller transaction can roll back.
*/
public function onAccountRenamed(string $old, string $new): void
{
if ($old === '' || $old === $new) {
return;
}
$this->releaseSourcesOf($new);
$this->repo->renameAccount($old, $new);
$this->tokens->renameOwner($old, $new);
}
/** Account deleted (after its jobs were deleted): sources released, tokens and user targets removed */
public function onAccountDeleted(string $old): void
{
if ($old === '') {
return;
}
$this->releaseSourcesOf($old);
$this->tokens->deleteByOwner($old);
$this->repo->deleteUserTarget($old);
}
private function canRead(): bool
{
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
}
/** @throws AccessDenied when the caller cannot see or edit the source */
private function editable(int $id): Source
{
$source = $this->get($id);
if ($source === null || !$this->canEdit($source)) {
throw new AccessDenied();
}
return $source;
}
/**
* Sources of an account are deleted when unused, otherwise handed over to the admins
* (created_by ''), so a re-created account inherits nothing.
*/
private function releaseSourcesOf(string $username): void
{
foreach ($this->repo->listCreatedBy($username) as $source) {
$id = (int)$source->id;
if (!$this->repo->isInUse($id)) {
// Explicit child deletes: the mailbox rename runs with FOREIGN_KEY_CHECKS = 0 (no cascade)
$this->repo->deleteWithChildren($id);
continue;
}
// Keep (created_by, name) unique when handing over
$name = $this->repo->nameExists($source->name, '') ? mb_substr($source->name, 0, 80) . ' (#' . $id . ')' : $source->name;
$this->repo->handOver($id, $name);
}
}
/**
* Name, description, connection and active from the request; missing attributes keep the value of $source.
* On edit, stored host/port/enc that do not change are not validated again, so migrated legacy sources
* stay editable (e.g. host 'imap_server.lan'). A host that only differs in case keeps the stored spelling.
*
* @throws UserError
*/
private function applyBasics(Source $source, array $input): void
{
$stored = ($source->id !== null);
$name = isset($input['name']) ? Input::text($input['name']) : $source->name;
$description = isset($input['description']) ? Input::text($input['description']) : $source->description;
$host = $input['host'] ?? $input['host1'] ?? null;
$host = ($host === null) ? $source->host : Input::text($host);
if ($host !== null) {
$host = (strtolower($host) === strtolower($source->host)) ? $source->host : strtolower($host);
}
$port = $input['port'] ?? $input['port1'] ?? $source->port;
$port = is_scalar($port) ? (int)$port : 0;
$enc = $input['enc'] ?? $input['enc1'] ?? $source->enc;
$authType = $input['auth_type'] ?? $source->authType;
if ($name === null || $name === '' || mb_strlen($name) > 100) {
throw new UserError('imapsync_source_name_invalid');
}
if ($description === null || mb_strlen($description) > 255) {
throw new UserError('imapsync_source_description_invalid');
}
if (!($stored && $host === $source->host) && !$this->isValidHost($host)) {
throw new UserError('imapsync_source_host_invalid');
}
if (!($stored && $port === $source->port) && ($port < 1 || $port > 65535)) {
throw new UserError('imapsync_source_port_invalid');
}
if (!($stored && $enc === $source->enc) && !in_array($enc, self::ENCRYPTIONS, true)) {
throw new UserError('imapsync_source_enc_invalid');
}
if (!in_array($authType, self::AUTH_TYPES, true)) {
throw new UserError('imapsync_source_auth_invalid');
}
$source->name = $name;
$source->description = $description;
$source->host = $host;
$source->port = $port;
$source->enc = $enc;
$source->authType = $authType;
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
}
private function isValidHost(?string $host): bool
{
return $host !== null && $host !== '' && strlen($host) <= 253
&& (filter_var($host, FILTER_VALIDATE_IP) || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
}
/** @throws UserError */
private function applyOAuth(Source $source, array $input): void
{
// XOAUTH2 sources are managed by admins and domain admins only
if ($source->isXOAuth2() && $this->context->isUser()) {
throw new AccessDenied();
}
$this->oauth->applySettings($source, $input);
}
/**
* Validates scope and targets for the caller and sets them; nothing is written here.
* Users' sources and client_credentials sources (one app-wide token that reaches every mailbox) are always private.
*
* @throws UserError access_denied, imapsync_source_scope_empty
*/
private function applyScope(Source $source, mixed $scope, mixed $domains, mixed $users): void
{
if ($source->usesClientCredentials() || $this->context->isUser()) {
[$source->scope, $source->domains, $source->users] = ['user', [], []];
return;
}
// Only admins may create globally visible sources
if (!is_string($scope) || !in_array($scope, self::SCOPES, true) || ($scope === 'all' && !$this->context->isAdmin())) {
throw new AccessDenied();
}
$domains = ($scope === 'domain') ? $this->cleanList($domains) : [];
$users = ($scope === 'user') ? $this->cleanList($users) : [];
if ($scope === 'domain' && empty($domains)) {
throw new UserError('imapsync_source_scope_empty');
}
foreach ($domains as $domain) {
if (!$this->context->canAccessDomain($domain)) {
throw new AccessDenied();
}
}
foreach ($users as $user) {
if (!$this->context->canAccessMailbox($user)) {
throw new AccessDenied();
}
}
[$source->scope, $source->domains, $source->users] = [$scope, $domains, $users];
}
/** @return string[] trimmed, lowercase, unique, non-empty scalar values */
private function cleanList(mixed $list): array
{
$clean = [];
foreach ((array)$list as $value) {
if (is_scalar($value) && trim((string)$value) !== '') {
$clean[] = strtolower(trim((string)$value));
}
}
return array_values(array_unique($clean));
}
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
private static function connection(Source $source): array
{
return [(string)$source->authType, $source->host, $source->port, (string)$source->enc, $source->oauthFlow,
(string)$source->tokenEndpoint, (string)$source->clientId];
}
}
+97
View File
@@ -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>/
<Module>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>Module::create()`. Procedural legacy callers (`json_api.php`, pages,
`functions.*.inc.php`) use `<Module>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 `[<legacy function>, <action>, <type>, <data>]` 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\<Module>\{Model,Repository,Service,Http,Controller}\<Class>`.
## 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>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/<Module>/...` (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`).
+1
View File
@@ -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'),
+5
View File
@@ -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',