mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-29 04:33:25 +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,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];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user