[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,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;
}
}