[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

@@ -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];
}
}