mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-24 11:38:54 +00:00
[Web] Add Mailcow\ class structure and imapsync module classes
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user