From 06a2a14c550f87bb530affb78b1d2c9d8e530840 Mon Sep 17 00:00:00 2001
From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:04:42 +0200
Subject: [PATCH] [Imapsync] Add oauth support
---
data/Dockerfiles/dovecot/imapsync_runner.pl | 114 +-
.../phpfpm/crons/imapsync-oauth-refresh.php | 100 ++
data/web/api/openapi.yaml | 405 +++++-
data/web/edit.php | 8 +-
data/web/inc/ajax/syncjob_logs.php | 2 +-
data/web/inc/footer.inc.php | 5 +-
data/web/inc/functions.mailbox.inc.php | 484 -------
data/web/inc/functions.syncjob.inc.php | 1259 +++++++++++++++++
data/web/inc/init_db.inc.php | 159 ++-
data/web/inc/prerequisites.inc.php | 1 +
data/web/inc/vars.inc.php | 3 -
data/web/js/build/013-mailcow.js | 131 ++
data/web/js/site/edit.js | 11 +
data/web/js/site/mailbox.js | 70 +-
data/web/js/site/user.js | 70 +-
data/web/json_api.php | 65 +-
data/web/lang/lang.de-de.json | 133 +-
data/web/lang/lang.en-gb.json | 133 +-
data/web/syncjob-oauth.php | 85 ++
data/web/templates/edit.twig | 1 +
data/web/templates/edit/imapsync_source.twig | 181 +++
data/web/templates/edit/syncjob.twig | 68 +-
data/web/templates/mailbox.twig | 3 +-
data/web/templates/mailbox/tab-syncjobs.twig | 35 +-
.../web/templates/modals/imapsync_source.twig | 161 +++
data/web/templates/modals/mailbox.twig | 72 +-
data/web/templates/modals/user.twig | 69 +-
data/web/templates/user.twig | 2 +-
data/web/templates/user/Syncjobs.twig | 29 +-
.../templates/user_domainadmin_common.twig | 1 +
docker-compose.yml | 5 +-
31 files changed, 3082 insertions(+), 783 deletions(-)
create mode 100644 data/conf/phpfpm/crons/imapsync-oauth-refresh.php
create mode 100644 data/web/inc/functions.syncjob.inc.php
create mode 100644 data/web/syncjob-oauth.php
create mode 100644 data/web/templates/edit/imapsync_source.twig
create mode 100644 data/web/templates/modals/imapsync_source.twig
diff --git a/data/Dockerfiles/dovecot/imapsync_runner.pl b/data/Dockerfiles/dovecot/imapsync_runner.pl
index 1030603ce..e16534f8c 100644
--- a/data/Dockerfiles/dovecot/imapsync_runner.pl
+++ b/data/Dockerfiles/dovecot/imapsync_runner.pl
@@ -51,40 +51,47 @@ sub sig_handler {
die "sig_handler received signal, preparing to exit...\n";
};
-open my $file, '<', "/etc/sogo/sieve.creds";
-my $creds = <$file>;
+open my $file, '<', "/etc/sogo/sieve.creds";
+my $creds = <$file>;
close $file;
my ($master_user, $master_pass) = split /:/, $creds;
-my $sth = $dbh->prepare("SELECT id,
- user1,
- user2,
- host1,
- authmech1,
- password1,
- exclude,
- port1,
- enc1,
- delete2duplicates,
- maxage,
- subfolder2,
- delete1,
- delete2,
- automap,
- skipcrossduplicates,
- maxbytespersecond,
- custom_params,
- subscribeall,
- timeout1,
- timeout2,
- dry
- FROM imapsync
- WHERE active = 1
- AND is_running = 0
+# Source-based schema: join imapsync_source to read host/port/encryption/auth-type
+my $sth = $dbh->prepare("SELECT
+ i.id,
+ i.user1,
+ i.user2,
+ s.host1,
+ s.auth_type,
+ i.password1,
+ i.exclude,
+ s.port1,
+ s.enc1,
+ i.delete2duplicates,
+ i.maxage,
+ i.subfolder2,
+ i.delete1,
+ i.delete2,
+ i.automap,
+ i.skipcrossduplicates,
+ i.maxbytespersecond,
+ i.custom_params,
+ i.subscribeall,
+ i.timeout1,
+ i.timeout2,
+ i.dry,
+ s.oauth_access_token,
+ s.oauth_token_expires,
+ s.oauth_flow,
+ i.source_id
+ FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
+ WHERE i.active = 1
+ AND s.active = 1
+ AND i.is_running = 0
AND (
- UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(last_run) > mins_interval * 60
+ UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60
OR
- last_run IS NULL)
- ORDER BY last_run");
+ i.last_run IS NULL)
+ ORDER BY i.last_run");
$sth->execute();
my $row;
@@ -95,7 +102,7 @@ while ($row = $sth->fetchrow_arrayref()) {
$user1 = @$row[1];
$user2 = @$row[2];
$host1 = @$row[3];
- $authmech1 = @$row[4];
+ $auth_type = @$row[4];
$password1 = @$row[5];
$exclude = @$row[6];
$port1 = @$row[7];
@@ -113,16 +120,53 @@ while ($row = $sth->fetchrow_arrayref()) {
$timeout1 = @$row[19];
$timeout2 = @$row[20];
$dry = @$row[21];
+ $oauth_access_token = @$row[22];
+ $oauth_token_expires = @$row[23];
+ $oauth_flow = @$row[24];
+ $source_id = @$row[25];
if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; }
my $template = $run_dir . '/imapsync.XXXXXXX';
my $passfile1 = File::Temp->new(TEMPLATE => $template);
my $passfile2 = File::Temp->new(TEMPLATE => $template);
-
+ my $tokenfile1;
+
binmode( $passfile1, ":utf8" );
-
- print $passfile1 "$password1\n";
+
+ # Auth-mode-specific argument set
+ my @auth_args;
+ if (defined($auth_type) && $auth_type eq 'XOAUTH2') {
+ # authorization_code sources use a per-user token (keyed by source + user1),
+ # not the shared source-level token used by client_credentials.
+ if (defined($oauth_flow) && $oauth_flow eq 'authorization_code') {
+ my $tsth = $dbh->prepare("SELECT access_token, token_expires FROM imapsync_source_oauth_token WHERE source_id=? AND username=?");
+ $tsth->execute($source_id, $user1);
+ my $trow = $tsth->fetchrow_arrayref();
+ $oauth_access_token = $trow ? @$trow[0] : undef;
+ $oauth_token_expires = $trow ? @$trow[1] : undef;
+ }
+ # Skip sync if cached token is missing or about to expire (<2 min)
+ if (!defined($oauth_access_token) || $oauth_access_token eq ''
+ || !defined($oauth_token_expires) || $oauth_token_expires - time() < 120) {
+ my $upd = $dbh->prepare("UPDATE imapsync SET returned_text=?, success=0, exit_status='OAUTH_TOKEN_NOT_READY', last_run=NOW(), is_running=0 WHERE id=?");
+ $upd->execute("OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.", $id);
+ next;
+ }
+ $tokenfile1 = File::Temp->new(TEMPLATE => $template);
+ binmode($tokenfile1, ":utf8");
+ print $tokenfile1 "$oauth_access_token\n";
+ @auth_args = ("--authmech1", "XOAUTH2",
+ "--oauthaccesstoken1", $tokenfile1->filename);
+ } else {
+ print $passfile1 "$password1\n";
+ @auth_args = ("--passfile1", $passfile1->filename);
+ # imapsync auto-picks PLAIN; only emit --authmech1 for LOGIN/CRAM-MD5
+ if (defined($auth_type) && $auth_type ne 'PLAIN') {
+ push @auth_args, "--authmech1", $auth_type;
+ }
+ }
+
print $passfile2 trim($master_pass) . "\n";
my @custom_params_a = qqw($custom_params);
@@ -147,7 +191,7 @@ while ($row = $sth->fetchrow_arrayref()) {
(!defined($enc1) ? () : ($enc1)),
"--host1", $host1,
"--user1", $user1,
- "--passfile1", $passfile1->filename,
+ @auth_args,
"--port1", $port1,
"--host2", "localhost",
"--user2", $user2 . '*' . trim($master_user),
diff --git a/data/conf/phpfpm/crons/imapsync-oauth-refresh.php b/data/conf/phpfpm/crons/imapsync-oauth-refresh.php
new file mode 100644
index 000000000..8388fa462
--- /dev/null
+++ b/data/conf/phpfpm/crons/imapsync-oauth-refresh.php
@@ -0,0 +1,100 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+];
+try {
+ $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
+} catch (PDOException $e) {
+ fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n");
+ exit(1);
+}
+
+$redis = new Redis();
+try {
+ if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
+ $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
+ } else {
+ $redis->connect('redis-mailcow', 6379);
+ }
+ $redis->auth(getenv("REDISPASS"));
+} catch (Exception $e) {
+ fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n");
+ exit(1);
+}
+
+function logMsg($priority, $message, $task = "imapsync OAuth refresh") {
+ global $redis;
+ $redis->lPush('CRON_LOG', json_encode(array(
+ "time" => time(),
+ "priority" => $priority,
+ "task" => $task,
+ "message" => $message,
+ )));
+}
+
+require_once __DIR__ . '/../web/inc/functions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
+require_once __DIR__ . '/../web/inc/sessions.inc.php';
+require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
+require_once __DIR__ . '/../web/inc/functions.syncjob.inc.php';
+
+// File-lock to prevent concurrent runs
+$lock_file = '/tmp/imapsync-oauth-refresh.lock';
+if (file_exists($lock_file)) {
+ $pid = (int)trim(@file_get_contents($lock_file));
+ if ($pid > 0 && posix_kill($pid, 0)) {
+ logMsg("info", "Previous refresh still running (pid=$pid), exiting");
+ exit(0);
+ }
+ @unlink($lock_file);
+}
+file_put_contents($lock_file, getmypid());
+
+try {
+ // 1) client_credentials sources: refresh the shared source-level token (expires < 5 min or none)
+ $stmt = $pdo->prepare("SELECT `id`, `name` FROM `imapsync_source`
+ WHERE `active` = 1 AND `auth_type` = 'XOAUTH2' AND `oauth_flow` = 'client_credentials'
+ AND (`oauth_token_expires` IS NULL OR `oauth_token_expires` < :soon)");
+ $stmt->execute(array(':soon' => time() + 300));
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $src) {
+ $ok = imapsync_source_refresh_token_internal($src['id']);
+ if ($ok) {
+ logMsg("info", "Refreshed token for source '" . $src['name'] . "' (id=" . $src['id'] . ")");
+ } else {
+ $e = $pdo->prepare("SELECT `oauth_last_refresh_error` FROM `imapsync_source` WHERE `id` = :id");
+ $e->execute(array(':id' => $src['id']));
+ logMsg("warning", "Refresh failed for source '" . $src['name'] . "' (id=" . $src['id'] . "): " . $e->fetchColumn());
+ }
+ }
+
+ // 2) authorization_code sources: refresh each per-user token via its stored refresh_token
+ $stmt = $pdo->prepare("SELECT t.`source_id`, t.`username`, s.`name`
+ 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` <> ''
+ AND (t.`token_expires` IS NULL OR t.`token_expires` < :soon)");
+ $stmt->execute(array(':soon' => time() + 300));
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $tok) {
+ $ok = imapsync_source_refresh_user_token_internal($tok['source_id'], $tok['username']);
+ if ($ok) {
+ logMsg("info", "Refreshed user token '" . $tok['username'] . "' on source '" . $tok['name'] . "' (id=" . $tok['source_id'] . ")");
+ } else {
+ $e = $pdo->prepare("SELECT `last_refresh_error` FROM `imapsync_source_oauth_token` WHERE `source_id` = :sid AND `username` = :user");
+ $e->execute(array(':sid' => $tok['source_id'], ':user' => $tok['username']));
+ logMsg("warning", "User-token refresh failed for '" . $tok['username'] . "' on source '" . $tok['name'] . "' (id=" . $tok['source_id'] . "): " . $e->fetchColumn());
+ }
+ }
+} finally {
+ @unlink($lock_file);
+}
diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml
index cf54f6add..2b34e87a0 100644
--- a/data/web/api/openapi.yaml
+++ b/data/web/api/openapi.yaml
@@ -1593,6 +1593,126 @@ paths:
description: enables or disables the sync job
type: boolean
type: object
+ /api/v1/add/syncjob_source:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - syncjob
+ - add
+ - source
+ - name: Example import
+ description: ""
+ host1: imap.example.org
+ port1: 993
+ enc1: SSL
+ auth_type: PLAIN
+ active: 1
+ is_global: 0
+ msg:
+ - imapsync_source_added
+ - Example import
+ type: success
+ schema:
+ properties:
+ log:
+ description: contains request object
+ items: {}
+ type: array
+ msg:
+ items: {}
+ type: array
+ type:
+ enum:
+ - success
+ - danger
+ - error
+ type: string
+ type: object
+ description: OK
+ headers: {}
+ tags:
+ - Sync jobs
+ description: >-
+ Create a reusable IMAP sync source. A sync job binds to a source by id;
+ the source carries host/port/encryption and either static credentials
+ (PLAIN/LOGIN/CRAM-MD5 — supplied per-job) or an OAuth2 token endpoint
+ (XOAUTH2 — token cached on the source row).
+ operationId: Create sync job source
+ summary: Create sync job source
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ name: Example import
+ description: ""
+ host1: imap.example.org
+ port1: "993"
+ enc1: SSL
+ auth_type: PLAIN
+ active: "1"
+ is_global: "0"
+ properties:
+ name:
+ description: source name, unique per owner
+ type: string
+ description:
+ description: free text shown in the UI
+ type: string
+ host1:
+ description: remote IMAP host
+ type: string
+ port1:
+ description: remote IMAP port (1-65535)
+ type: integer
+ enc1:
+ description: TLS / SSL / PLAIN
+ type: string
+ enum:
+ - TLS
+ - SSL
+ - PLAIN
+ auth_type:
+ description: authentication mechanism
+ type: string
+ enum:
+ - PLAIN
+ - LOGIN
+ - CRAM-MD5
+ - XOAUTH2
+ oauth_token_endpoint:
+ description: required when auth_type is XOAUTH2 (token URL)
+ type: string
+ oauth_client_id:
+ description: required when auth_type is XOAUTH2
+ type: string
+ oauth_client_secret:
+ description: required when auth_type is XOAUTH2
+ type: string
+ oauth_scope:
+ description: required when auth_type is XOAUTH2
+ type: string
+ oauth_extra_params:
+ description: optional JSON object merged into the token request body
+ type: string
+ active:
+ description: enables or disables the source
+ type: boolean
+ is_global:
+ description: admin-only — make the source available to everyone
+ type: boolean
+ owner:
+ description: admin-only — assign ownership to a specific username
+ type: string
+ type: object
/api/v1/add/tls-policy-map:
post:
responses:
@@ -2510,7 +2630,7 @@ paths:
description: >-
Using this endpoint you can perform actions on quarantine items. It is possible to release
emails from quarantine into to the inbox, or learn them as ham to improve Rspamd filtering.
- You must provide the quarantine item IDs. You can get the IDs using the GET method.
+ You must provide the quarantine item IDs. You can get the IDs using the GET method.
operationId: Edit mails in Quarantine
requestBody:
content:
@@ -2765,6 +2885,62 @@ paths:
type: object
type: object
summary: Delete sync job
+ /api/v1/delete/syncjob_source:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - syncjob
+ - delete
+ - source
+ - id:
+ - "3"
+ msg:
+ - imapsync_source_deleted
+ - Example import
+ type: success
+ schema:
+ properties:
+ log:
+ description: contains request object
+ items: {}
+ type: array
+ msg:
+ items: {}
+ type: array
+ type:
+ enum:
+ - success
+ - danger
+ - error
+ type: string
+ type: object
+ description: OK
+ headers: {}
+ tags:
+ - Sync jobs
+ description: >-
+ Delete one or more sync job sources by id. Sources still referenced by
+ a sync job cannot be deleted (FK ON DELETE RESTRICT) — delete or
+ repoint the dependent jobs first.
+ operationId: Delete sync job source
+ summary: Delete sync job source
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ - "3"
+ items:
+ type: string
+ type: array
/api/v1/delete/tls-policy-map:
post:
responses:
@@ -3931,6 +4107,177 @@ paths:
type: object
type: object
summary: Update sync job
+ /api/v1/edit/syncjob_source:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - syncjob
+ - edit
+ - source
+ - id:
+ - "3"
+ name: Example import
+ host1: imap.example.org
+ port1: 993
+ msg:
+ - imapsync_source_modified
+ - Example import
+ type: success
+ schema:
+ properties:
+ log:
+ description: contains request object
+ items: {}
+ type: array
+ msg:
+ items: {}
+ type: array
+ type:
+ enum:
+ - success
+ - danger
+ - error
+ type: string
+ type: object
+ description: OK
+ headers: {}
+ tags:
+ - Sync jobs
+ description: >-
+ Update one or more sync job sources. Fields not provided are left
+ unchanged. For XOAUTH2 sources, an empty `oauth_client_secret`
+ preserves the stored value.
+ operationId: Update sync job source
+ summary: Update sync job source
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ items:
+ - "3"
+ attr:
+ name: Example import
+ description: ""
+ host1: imap.example.org
+ port1: "993"
+ enc1: SSL
+ auth_type: PLAIN
+ active: "1"
+ properties:
+ attr:
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ host1:
+ type: string
+ port1:
+ type: integer
+ enc1:
+ type: string
+ enum:
+ - TLS
+ - SSL
+ - PLAIN
+ auth_type:
+ type: string
+ enum:
+ - PLAIN
+ - LOGIN
+ - CRAM-MD5
+ - XOAUTH2
+ oauth_token_endpoint:
+ type: string
+ oauth_client_id:
+ type: string
+ oauth_client_secret:
+ description: empty keeps the previously stored secret
+ type: string
+ oauth_scope:
+ type: string
+ oauth_extra_params:
+ type: string
+ active:
+ type: boolean
+ type: object
+ items:
+ description: list of source ids to update
+ type: array
+ items:
+ type: string
+ type: object
+ /api/v1/edit/syncjob_source/refresh_token:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - syncjob
+ - edit
+ - refresh_token
+ - id:
+ - "3"
+ msg:
+ - imapsync_source_token_refreshed
+ - Example import
+ type: success
+ schema:
+ properties:
+ log:
+ description: contains request object
+ items: {}
+ type: array
+ msg:
+ items: {}
+ type: array
+ type:
+ enum:
+ - success
+ - danger
+ - error
+ type: string
+ type: object
+ description: OK
+ headers: {}
+ tags:
+ - Sync jobs
+ description: >-
+ Force an OAuth2 access-token refresh for a XOAUTH2 source. Performs a
+ Client-Credentials grant against the source's `oauth_token_endpoint`
+ and caches the new access token + expiry on the source row. On failure,
+ the error is recorded in `oauth_last_refresh_error`.
+ operationId: Refresh sync job source token
+ summary: Refresh sync job source token
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ items:
+ - "3"
+ properties:
+ items:
+ description: list of source ids to refresh
+ type: array
+ items:
+ type: string
+ type: object
/api/v1/edit/user-acl:
post:
responses:
@@ -5629,6 +5976,62 @@ paths:
description: You can list all syn jobs existing in system.
operationId: Get sync jobs
summary: Get sync jobs
+ "/api/v1/get/syncjob_source/{id}":
+ get:
+ parameters:
+ - description: source id, or `all` to list every source visible to the session
+ example: all
+ in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ - description: e.g. api-key-string
+ example: api-key-string
+ in: header
+ name: X-API-Key
+ required: false
+ schema:
+ type: string
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - id: 3
+ name: Example import
+ description: ""
+ owner: null
+ host1: imap.example.org
+ port1: 993
+ enc1: SSL
+ auth_type: PLAIN
+ oauth_token_endpoint: null
+ oauth_client_id: null
+ oauth_client_secret: ""
+ oauth_scope: null
+ oauth_extra_params: null
+ oauth_access_token: ""
+ oauth_token_expires: null
+ oauth_last_refresh_error: null
+ active: 1
+ created: "2026-06-11 09:00:00"
+ modified: "2026-06-11 09:00:00"
+ description: OK
+ headers: {}
+ tags:
+ - Sync jobs
+ description: >-
+ Returns a single sync job source by id, or all sources visible to the
+ caller when `id` is `all`. Secrets (`oauth_client_secret`,
+ `oauth_access_token`) are blanked unless requested with internal
+ flags — they are never returned over the JSON API.
+ operationId: Get sync job sources
+ summary: Get sync job sources
"/api/v1/get/tls-policy-map/{id}":
get:
parameters:
diff --git a/data/web/edit.php b/data/web/edit.php
index 48f2309c1..e786f6df7 100644
--- a/data/web/edit.php
+++ b/data/web/edit.php
@@ -204,9 +204,15 @@ if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_GET['syncjob']) &&
is_numeric($_GET['syncjob'])) {
$id = $_GET["syncjob"];
- $result = mailbox('get', 'syncjob_details', $id);
+ $result = syncjob('get', 'job', $id);
$template = 'edit/syncjob.twig';
}
+ elseif (isset($_GET['syncjob_source']) &&
+ is_numeric($_GET['syncjob_source'])) {
+ $id = $_GET["syncjob_source"];
+ $result = syncjob('get', 'source', array('id' => $id, 'with_secret' => true));
+ $template = 'edit/imapsync_source.twig';
+ }
elseif (isset($_GET['filter']) &&
is_numeric($_GET['filter'])) {
$id = $_GET["filter"];
diff --git a/data/web/inc/ajax/syncjob_logs.php b/data/web/inc/ajax/syncjob_logs.php
index 9c521aec7..23daec0eb 100644
--- a/data/web/inc/ajax/syncjob_logs.php
+++ b/data/web/inc/ajax/syncjob_logs.php
@@ -6,7 +6,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) {
}
if (isset($_GET['id']) && is_numeric($_GET['id'])) {
- if ($details = mailbox('get', 'syncjob_details', intval($_GET['id']))) {
+ if ($details = syncjob('get', 'job', intval($_GET['id']))) {
echo (empty($details['log'])) ? '-' : $details['log'];
}
}
diff --git a/data/web/inc/footer.inc.php b/data/web/inc/footer.inc.php
index 4ced152ae..01dc2c532 100644
--- a/data/web/inc/footer.inc.php
+++ b/data/web/inc/footer.inc.php
@@ -33,11 +33,11 @@ if (array_key_exists('pending_tfa_methods', $_SESSION)) {
if (isset($pending_tfa_authmechs['webauthn'])) {
$pending_tfa_authmechs['webauthn'] = true;
}
- if (!isset($pending_tfa_authmechs['webauthn'])
+ if (!isset($pending_tfa_authmechs['webauthn'])
&& isset($pending_tfa_authmechs['yubi_otp'])) {
$pending_tfa_authmechs['yubi_otp'] = true;
}
- if (!isset($pending_tfa_authmechs['webauthn'])
+ if (!isset($pending_tfa_authmechs['webauthn'])
&& !isset($pending_tfa_authmechs['yubi_otp'])
&& isset($pending_tfa_authmechs['totp'])) {
$pending_tfa_authmechs['totp'] = true;
@@ -72,6 +72,7 @@ $globalVariables = [
'lang_fido2' => json_encode($lang['fido2']),
'lang_success' => json_encode($lang['success']),
'lang_danger' => json_encode($lang['danger']),
+ 'lang_syncjobs' => json_encode($lang['syncjobs']),
'docker_timeout' => $DOCKER_TIMEOUT,
'session_lifetime' => (int)$SESSION_LIFETIME,
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php
index adb330ea8..27ad01c38 100644
--- a/data/web/inc/functions.mailbox.inc.php
+++ b/data/web/inc/functions.mailbox.inc.php
@@ -299,192 +299,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
'msg' => array('mailbox_modified', $username)
);
break;
- case 'syncjob':
- if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
- if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- else {
- $username = $_data['username'];
- }
- }
- elseif ($_SESSION['mailcow_cc_role'] == "user") {
- $username = $_SESSION['mailcow_cc_username'];
- }
- else {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'no_user_defined'
- );
- return false;
- }
- $active = intval($_data['active']);
- $subscribeall = intval($_data['subscribeall']);
- $delete2duplicates = intval($_data['delete2duplicates']);
- $delete1 = intval($_data['delete1']);
- $delete2 = intval($_data['delete2']);
- $timeout1 = intval($_data['timeout1']);
- $timeout2 = intval($_data['timeout2']);
- $skipcrossduplicates = intval($_data['skipcrossduplicates']);
- $automap = intval($_data['automap']);
- $dry = intval($_data['dry']);
- $port1 = $_data['port1'];
- $host1 = strtolower($_data['host1']);
- $password1 = $_data['password1'];
- $exclude = $_data['exclude'];
- $maxage = $_data['maxage'];
- $maxbytespersecond = $_data['maxbytespersecond'];
- $subfolder2 = $_data['subfolder2'];
- $user1 = $_data['user1'];
- $mins_interval = $_data['mins_interval'];
- $enc1 = $_data['enc1'];
- $custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
-
- // validate custom params
- foreach (explode('-', $custom_params) as $param){
- if(empty($param)) continue;
-
- // extract option
- if (str_contains($param, '=')) $param = explode('=', $param)[0];
- else $param = rtrim($param, ' ');
- // remove first char if first char is -
- if ($param[0] == '-') $param = ltrim($param, $param[0]);
-
- if (str_contains($param, ' ')) {
- // bad char
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'bad character SPACE'
- );
- return false;
- }
-
- // check if param is whitelisted
- if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
- // bad option
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'bad option '. $param
- );
- return false;
- }
- }
- if (empty($subfolder2)) {
- $subfolder2 = "";
- }
- if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $maxage = "0";
- }
- if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $timeout1 = "600";
- }
- if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $timeout2 = "600";
- }
- if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
- $maxbytespersecond = "0";
- }
- if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- // if (!is_valid_domain_name($host1)) {
- // $_SESSION['return'][] = array(
- // 'type' => 'danger',
- // 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- // 'msg' => 'access_denied'
- // );
- // return false;
- // }
- if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- if (@preg_match("/" . $exclude . "/", null) === false) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- $stmt = $pdo->prepare("SELECT '1' FROM `imapsync`
- WHERE `user2` = :user2 AND `user1` = :user1 AND `host1` = :host1");
- $stmt->execute(array(':user1' => $user1, ':user2' => $username, ':host1' => $host1));
- $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
- if ($num_results != 0) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => array('object_exists', htmlspecialchars($host1 . ' / ' . $user1))
- );
- return false;
- }
- $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`)
- VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)");
- $stmt->execute(array(
- ':user2' => $username,
- ':custom_params' => $custom_params,
- ':exclude' => $exclude,
- ':maxage' => $maxage,
- ':delete1' => $delete1,
- ':delete2' => $delete2,
- ':timeout1' => $timeout1,
- ':timeout2' => $timeout2,
- ':automap' => $automap,
- ':skipcrossduplicates' => $skipcrossduplicates,
- ':maxbytespersecond' => $maxbytespersecond,
- ':subscribeall' => $subscribeall,
- ':dry' => $dry,
- ':subfolder2' => $subfolder2,
- ':host1' => $host1,
- ':authmech1' => 'PLAIN',
- ':user1' => $user1,
- ':password1' => $password1,
- ':mins_interval' => $mins_interval,
- ':port1' => $port1,
- ':enc1' => $enc1,
- ':delete2duplicates' => $delete2duplicates,
- ':active' => $active,
- ));
- $_SESSION['return'][] = array(
- 'type' => 'success',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => array('mailbox_modified', $username)
- );
- break;
case 'domain':
if ($_SESSION['mailcow_cc_role'] != "admin") {
$_SESSION['return'][] = array(
@@ -2265,202 +2079,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
);
}
break;
- case 'syncjob':
- if (!is_array($_data['id'])) {
- $ids = array();
- $ids[] = $_data['id'];
- }
- else {
- $ids = $_data['id'];
- }
- if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- foreach ($ids as $id) {
- $is_now = mailbox('get', 'syncjob_details', $id, array('with_password'));
- if (!empty($is_now)) {
- $username = $is_now['user2'];
- $user1 = (!empty($_data['user1'])) ? $_data['user1'] : $is_now['user1'];
- $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
- $last_run = (isset($_data['last_run'])) ? NULL : $is_now['last_run'];
- $success = (isset($_data['success'])) ? NULL : $is_now['success'];
- $delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates'];
- $subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall'];
- $dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry'];
- $delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1'];
- $delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2'];
- $automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap'];
- $skipcrossduplicates = (isset($_data['skipcrossduplicates'])) ? intval($_data['skipcrossduplicates']) : $is_now['skipcrossduplicates'];
- $port1 = (!empty($_data['port1'])) ? $_data['port1'] : $is_now['port1'];
- $password1 = (!empty($_data['password1'])) ? $_data['password1'] : $is_now['password1'];
- $host1 = (!empty($_data['host1'])) ? $_data['host1'] : $is_now['host1'];
- $subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2'];
- $enc1 = (!empty($_data['enc1'])) ? $_data['enc1'] : $is_now['enc1'];
- $mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval'];
- $exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude'];
- $custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params'];
- $maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage'];
- $maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond'];
- $timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1'];
- $timeout2 = (isset($_data['timeout2']) && $_data['timeout2'] != "") ? intval($_data['timeout2']) : $is_now['timeout2'];
- }
- else {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
-
- // validate custom params
- foreach (explode('-', $custom_params) as $param){
- if(empty($param)) continue;
-
- // extract option
- if (str_contains($param, '=')) $param = explode('=', $param)[0];
- else $param = rtrim($param, ' ');
- // remove first char if first char is -
- if ($param[0] == '-') $param = ltrim($param, $param[0]);
-
- if (str_contains($param, ' ')) {
- // bad char
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'bad character SPACE'
- );
- return false;
- }
-
- // check if param is whitelisted
- if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
- // bad option
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'bad option '. $param
- );
- return false;
- }
- }
- if (empty($subfolder2)) {
- $subfolder2 = "";
- }
- if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $maxage = "0";
- }
- if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $timeout1 = "600";
- }
- if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
- $timeout2 = "600";
- }
- if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
- $maxbytespersecond = "0";
- }
- if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- if (!is_valid_domain_name($host1)) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- if (@preg_match("/" . $exclude . "/", null) === false) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- $stmt = $pdo->prepare("UPDATE `imapsync` SET `delete1` = :delete1,
- `delete2` = :delete2,
- `automap` = :automap,
- `skipcrossduplicates` = :skipcrossduplicates,
- `maxage` = :maxage,
- `maxbytespersecond` = :maxbytespersecond,
- `subfolder2` = :subfolder2,
- `exclude` = :exclude,
- `host1` = :host1,
- `last_run` = :last_run,
- `success` = :success,
- `user1` = :user1,
- `password1` = :password1,
- `mins_interval` = :mins_interval,
- `port1` = :port1,
- `enc1` = :enc1,
- `delete2duplicates` = :delete2duplicates,
- `custom_params` = :custom_params,
- `timeout1` = :timeout1,
- `timeout2` = :timeout2,
- `subscribeall` = :subscribeall,
- `dry` = :dry,
- `active` = :active
- WHERE `id` = :id");
- $stmt->execute(array(
- ':delete1' => $delete1,
- ':delete2' => $delete2,
- ':automap' => $automap,
- ':skipcrossduplicates' => $skipcrossduplicates,
- ':id' => $id,
- ':exclude' => $exclude,
- ':maxage' => $maxage,
- ':maxbytespersecond' => $maxbytespersecond,
- ':subfolder2' => $subfolder2,
- ':host1' => $host1,
- ':user1' => $user1,
- ':password1' => $password1,
- ':last_run' => $last_run,
- ':success' => $success,
- ':mins_interval' => $mins_interval,
- ':port1' => $port1,
- ':enc1' => $enc1,
- ':delete2duplicates' => $delete2duplicates,
- ':custom_params' => $custom_params,
- ':timeout1' => $timeout1,
- ':timeout2' => $timeout2,
- ':subscribeall' => $subscribeall,
- ':dry' => $dry,
- ':active' => $active,
- ));
- $_SESSION['return'][] = array(
- 'type' => 'success',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => array('mailbox_modified', $username)
- );
- }
- break;
case 'filter':
if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) {
$_SESSION['return'][] = array(
@@ -4575,68 +4193,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
}
return false;
break;
- case 'syncjob_details':
- $syncjobdetails = array();
- if (!is_numeric($_data)) {
- return false;
- }
- if (isset($_extra) && in_array('no_log', $_extra)) {
- $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("returned_text", "password1")');
- $fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
- while($field = array_shift($fields)) {
- $shown_fields[] = $field['Field'];
- }
- $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
- `active`
- FROM `imapsync` WHERE id = :id");
- }
- elseif (isset($_extra) && in_array('with_password', $_extra)) {
- $stmt = $pdo->prepare("SELECT *,
- `active`
- FROM `imapsync` WHERE id = :id");
- }
- else {
- $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("password1")');
- $fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
- while($field = array_shift($fields)) {
- $shown_fields[] = $field['Field'];
- }
- $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
- `active`
- FROM `imapsync` WHERE id = :id");
- }
- $stmt->execute(array(':id' => $_data));
- $syncjobdetails = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!empty($syncjobdetails['returned_text'])) {
- $syncjobdetails['log'] = $syncjobdetails['returned_text'];
- }
- else {
- $syncjobdetails['log'] = '';
- }
- unset($syncjobdetails['returned_text']);
- if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $syncjobdetails['user2'])) {
- return false;
- }
- return $syncjobdetails;
- break;
- case 'syncjobs':
- $syncjobdata = array();
- if (isset($_data) && filter_var($_data, FILTER_VALIDATE_EMAIL)) {
- if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
- return false;
- }
- }
- else {
- $_data = $_SESSION['mailcow_cc_username'];
- }
- $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username");
- $stmt->execute(array(':username' => $_data));
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
- while($row = array_shift($rows)) {
- $syncjobdata[] = $row['id'];
- }
- return $syncjobdata;
- break;
case 'spam_score':
$curl = curl_init();
curl_setopt($curl, CURLOPT_UNIX_SOCKET_PATH, '/var/lib/rspamd/rspamd.sock');
@@ -5458,46 +5014,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
break;
case 'delete':
switch ($_type) {
- case 'syncjob':
- if (!is_array($_data['id'])) {
- $ids = array();
- $ids[] = $_data['id'];
- }
- else {
- $ids = $_data['id'];
- }
- if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- return false;
- }
- foreach ($ids as $id) {
- if (!is_numeric($id)) {
- return false;
- }
- $stmt = $pdo->prepare("SELECT `user2` FROM `imapsync` WHERE id = :id");
- $stmt->execute(array(':id' => $id));
- $user2 = $stmt->fetch(PDO::FETCH_ASSOC)['user2'];
- if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $user2)) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => 'access_denied'
- );
- continue;
- }
- $stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id");
- $stmt->execute(array(':id' => $id));
- $_SESSION['return'][] = array(
- 'type' => 'success',
- 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
- 'msg' => array('deleted_syncjob', $id)
- );
- }
- break;
case 'filter':
if (!is_array($_data['id'])) {
$ids = array();
diff --git a/data/web/inc/functions.syncjob.inc.php b/data/web/inc/functions.syncjob.inc.php
new file mode 100644
index 000000000..12f048117
--- /dev/null
+++ b/data/web/inc/functions.syncjob.inc.php
@@ -0,0 +1,1259 @@
+ 'danger',
+ 'log' => array('imapsync_source_apply_scope', 'edit', 'source', $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ };
+
+ if (!in_array($scope, array('all', 'domain', 'user'))) return $deny();
+ // Only admins may create globally visible sources; users are always private.
+ if ($scope == 'all' && $role != 'admin') return $deny();
+ if ($role == 'user') { $scope = 'user'; $domains = array(); $users = array(); }
+
+ // Guardrail: an XOAUTH2 client_credentials source uses one app-wide token that can reach
+ // every mailbox — it must never be shareable. Force it private to its creator.
+ $src = $pdo->prepare("SELECT `auth_type`, `oauth_flow` FROM `imapsync_source` WHERE `id` = :id");
+ $src->execute(array(':id' => $source_id));
+ $src = $src->fetch(PDO::FETCH_ASSOC);
+ if ($src && $src['auth_type'] == 'XOAUTH2' && $src['oauth_flow'] == 'client_credentials') {
+ $scope = 'user'; $domains = array(); $users = array();
+ }
+
+ $domains = ($scope == 'domain') ? array_unique(array_filter((array)$domains)) : array();
+ $users = ($scope == 'user') ? array_unique(array_filter((array)$users)) : array();
+
+ // A user-scoped source with no explicit targets is private to its creator (valid).
+ // A domain-scoped source must name at least one domain.
+ if ($scope == 'domain' && empty($domains)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array('imapsync_source_apply_scope', 'edit', 'source', $_data_log),
+ 'msg' => 'imapsync_source_scope_empty'
+ );
+ return false;
+ }
+
+ foreach ($domains as $d) {
+ if (!hasDomainAccess($username, $role, $d)) return $deny();
+ }
+ foreach ($users as $u) {
+ if (!hasMailboxObjectAccess($username, $role, $u)) return $deny();
+ }
+
+ $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id")->execute(array(':id' => $source_id));
+ $pdo->prepare("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id")->execute(array(':id' => $source_id));
+ $ins_d = $pdo->prepare("INSERT INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
+ foreach ($domains as $d) { $ins_d->execute(array(':id' => $source_id, ':domain' => $d)); }
+ $ins_u = $pdo->prepare("INSERT INTO `imapsync_source_user` (`source_id`, `username`) VALUES (:id, :username)");
+ foreach ($users as $u) { $ins_u->execute(array(':id' => $source_id, ':username' => $u)); }
+ // apply_scope owns the scope column so the guardrail/coercion above stays consistent with the targets
+ $pdo->prepare("UPDATE `imapsync_source` SET `scope` = :scope WHERE `id` = :id")
+ ->execute(array(':scope' => $scope, ':id' => $source_id));
+ return true;
+}
+
+function imapsync_source_refresh_token_internal($source_id) {
+ // Context-free: invoked by web UI (with ACL upstream) and by cron (no session).
+ // Performs OAuth2 Client-Credentials Grant against source.oauth_token_endpoint,
+ // stores the resulting access_token + absolute expiry timestamp, or records the error.
+ global $pdo;
+
+ $stmt = $pdo->prepare("SELECT * FROM `imapsync_source` WHERE `id` = :id AND `auth_type` = 'XOAUTH2'");
+ $stmt->execute(array(':id' => $source_id));
+ $src = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$src) return false;
+
+ $body = array(
+ 'grant_type' => 'client_credentials',
+ 'client_id' => $src['oauth_client_id'],
+ 'client_secret' => $src['oauth_client_secret'],
+ 'scope' => $src['oauth_scope'],
+ );
+ if (!empty($src['oauth_extra_params'])) {
+ $extra = json_decode($src['oauth_extra_params'], true);
+ if (is_array($extra)) {
+ $body = array_merge($body, $extra);
+ }
+ }
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $src['oauth_token_endpoint']);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($body));
+ $response = curl_exec($ch);
+ $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curl_err = curl_error($ch);
+ curl_close($ch);
+
+ $record_error = function($message) use ($pdo, $source_id) {
+ $stmt = $pdo->prepare("UPDATE `imapsync_source` SET `oauth_last_refresh_error` = :err WHERE `id` = :id");
+ $stmt->execute(array(':err' => $message, ':id' => $source_id));
+ };
+
+ if ($response === false) {
+ $record_error("curl error: " . $curl_err);
+ return false;
+ }
+ if ($http_code < 200 || $http_code >= 300) {
+ $record_error("HTTP $http_code: " . substr($response, 0, 500));
+ return false;
+ }
+ $data = json_decode($response, true);
+ if (!is_array($data) || empty($data['access_token']) || !isset($data['expires_in'])) {
+ $record_error("malformed token response: " . substr($response, 0, 500));
+ return false;
+ }
+ $expires_at = time() + intval($data['expires_in']);
+
+ $stmt = $pdo->prepare("UPDATE `imapsync_source`
+ SET `oauth_access_token` = :tok,
+ `oauth_token_expires` = :exp,
+ `oauth_last_refresh_error` = NULL
+ WHERE `id` = :id");
+ $stmt->execute(array(
+ ':tok' => $data['access_token'],
+ ':exp' => $expires_at,
+ ':id' => $source_id,
+ ));
+ return true;
+}
+
+function imapsync_source_token_post($endpoint, $body) {
+ // Shared token-endpoint POST (application/x-www-form-urlencoded). Returns the decoded
+ // token array on success, or array('error' => '...') on failure. Used by the
+ // authorization_code exchange and the per-user refresh_token grant.
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $endpoint);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($body));
+ $response = curl_exec($ch);
+ $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curl_err = curl_error($ch);
+ curl_close($ch);
+
+ if ($response === false) return array('error' => "curl error: " . $curl_err);
+ if ($http_code < 200 || $http_code >= 300) return array('error' => "HTTP $http_code: " . substr($response, 0, 500));
+ $data = json_decode($response, true);
+ if (!is_array($data) || empty($data['access_token']) || !isset($data['expires_in'])) {
+ return array('error' => "malformed token response: " . substr($response, 0, 500));
+ }
+ return $data;
+}
+
+function imapsync_source_oauth_authorize_url($source, $redirect_uri, $state) {
+ // Builds the provider authorization URL for the authorization_code flow.
+ $params = array(
+ 'client_id' => $source['oauth_client_id'],
+ 'response_type' => 'code',
+ 'redirect_uri' => $redirect_uri,
+ 'response_mode' => 'query',
+ 'scope' => $source['oauth_scope'],
+ 'state' => $state,
+ );
+ $sep = (strpos($source['oauth_authorize_endpoint'], '?') === false) ? '?' : '&';
+ return $source['oauth_authorize_endpoint'] . $sep . http_build_query($params);
+}
+
+function imapsync_source_oauth_identity($source, $token) {
+ // Resolves the authenticated remote address from an authorization_code token response.
+ // Prefers the id_token claims (no extra HTTP call); falls back to the userinfo endpoint.
+ $pick = function($claims) {
+ foreach (array('email', 'preferred_username', 'upn', 'unique_name') as $k) {
+ if (!empty($claims[$k]) && filter_var($claims[$k], FILTER_VALIDATE_EMAIL)) return strtolower($claims[$k]);
+ }
+ return null;
+ };
+ if (!empty($token['id_token'])) {
+ $parts = explode('.', $token['id_token']);
+ if (count($parts) === 3) {
+ $claims = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
+ if (is_array($claims) && ($addr = $pick($claims))) return $addr;
+ }
+ }
+ if (!empty($source['oauth_userinfo_endpoint'])) {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $source['oauth_userinfo_endpoint']);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Bearer ' . $token['access_token']));
+ $response = curl_exec($ch);
+ curl_close($ch);
+ $info = json_decode((string)$response, true);
+ if (is_array($info) && ($addr = $pick($info))) return $addr;
+ }
+ return null;
+}
+
+function imapsync_source_store_user_token($source_id, $username, $token) {
+ // Upserts a per-user delegated token row for an authorization_code source.
+ global $pdo;
+ $stmt = $pdo->prepare("INSERT INTO `imapsync_source_oauth_token`
+ (`source_id`, `username`, `access_token`, `refresh_token`, `token_expires`, `last_refresh_error`)
+ VALUES (:sid, :user, :acc, :ref, :exp, NULL)
+ ON DUPLICATE KEY UPDATE
+ `access_token` = VALUES(`access_token`),
+ `refresh_token` = VALUES(`refresh_token`),
+ `token_expires` = VALUES(`token_expires`),
+ `last_refresh_error` = NULL");
+ $stmt->execute(array(
+ ':sid' => $source_id,
+ ':user' => $username,
+ ':acc' => $token['access_token'],
+ // Microsoft only returns a refresh_token when offline_access was requested; keep old one otherwise
+ ':ref' => $token['refresh_token'] ?? '',
+ ':exp' => time() + intval($token['expires_in']),
+ ));
+}
+
+function imapsync_source_refresh_user_token_internal($source_id, $username) {
+ // Context-free per-user refresh (web + cron). Uses the stored refresh_token to obtain a
+ // fresh access_token for one (source, user); records the error on the token row otherwise.
+ global $pdo;
+ $stmt = $pdo->prepare("SELECT s.*, t.`refresh_token`
+ FROM `imapsync_source_oauth_token` t
+ JOIN `imapsync_source` s ON s.`id` = t.`source_id`
+ WHERE t.`source_id` = :sid AND t.`username` = :user
+ AND s.`auth_type` = 'XOAUTH2' AND s.`oauth_flow` = 'authorization_code'");
+ $stmt->execute(array(':sid' => $source_id, ':user' => $username));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row || empty($row['refresh_token'])) return false;
+
+ $result = imapsync_source_token_post($row['oauth_token_endpoint'], array(
+ 'grant_type' => 'refresh_token',
+ 'client_id' => $row['oauth_client_id'],
+ 'client_secret' => $row['oauth_client_secret'],
+ 'refresh_token' => $row['refresh_token'],
+ 'scope' => $row['oauth_scope'],
+ ));
+ if (isset($result['error'])) {
+ $upd = $pdo->prepare("UPDATE `imapsync_source_oauth_token` SET `last_refresh_error` = :err
+ WHERE `source_id` = :sid AND `username` = :user");
+ $upd->execute(array(':err' => $result['error'], ':sid' => $source_id, ':user' => $username));
+ return false;
+ }
+ // Some providers omit a new refresh_token on refresh — keep the existing one then
+ if (empty($result['refresh_token'])) $result['refresh_token'] = $row['refresh_token'];
+ imapsync_source_store_user_token($source_id, $username, $result);
+ return true;
+}
+
+function syncjob($_action, $_type, $_data = null, $_extra = null) {
+ global $pdo;
+ global $lang;
+ $_data_log = $_data;
+
+ switch ($_action) {
+ case 'add':
+ switch ($_type) {
+ case 'job':
+ if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
+ if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ else {
+ $username = $_data['username'];
+ }
+ }
+ elseif ($_SESSION['mailcow_cc_role'] == "user") {
+ $username = $_SESSION['mailcow_cc_username'];
+ }
+ else {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'no_user_defined'
+ );
+ return false;
+ }
+ $active = intval($_data['active']);
+ $subscribeall = intval($_data['subscribeall']);
+ $delete2duplicates = intval($_data['delete2duplicates']);
+ $delete1 = intval($_data['delete1']);
+ $delete2 = intval($_data['delete2']);
+ $timeout1 = intval($_data['timeout1']);
+ $timeout2 = intval($_data['timeout2']);
+ $skipcrossduplicates = intval($_data['skipcrossduplicates']);
+ $automap = intval($_data['automap']);
+ $dry = intval($_data['dry']);
+ $source_id = intval($_data['source_id'] ?? 0);
+ $password1 = $_data['password1'] ?? '';
+ $exclude = $_data['exclude'];
+ $maxage = $_data['maxage'];
+ $maxbytespersecond = $_data['maxbytespersecond'];
+ $subfolder2 = $_data['subfolder2'];
+ $user1 = $_data['user1'];
+ $mins_interval = $_data['mins_interval'];
+ $custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
+
+ // Resolve and authorize the chosen sync source. Visibility is enforced by syncjob('get', 'source').
+ $source = syncjob('get', 'source', array('id' => $source_id));
+ if (empty($source) || intval($source['active']) != 1) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'imapsync_source_unavailable'
+ );
+ return false;
+ }
+ if ($source['auth_type'] == 'XOAUTH2') {
+ // OAuth2 sources do not carry a password; the runner uses a cached access_token.
+ $password1 = '';
+ // authorization_code: the remote account must have completed the per-user login first.
+ // This binds user1 to an actually-authenticated identity (no pulling foreign mailboxes).
+ if ($source['oauth_flow'] == 'authorization_code') {
+ $tok = $pdo->prepare("SELECT 1 FROM `imapsync_source_oauth_token`
+ WHERE `source_id` = :sid AND `username` = :user");
+ $tok->execute(array(':sid' => $source_id, ':user' => $user1));
+ if (!$tok->fetch(PDO::FETCH_ASSOC)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'imapsync_source_oauth_connect_required'
+ );
+ return false;
+ }
+ }
+ } elseif ($password1 === '') {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'password_empty'
+ );
+ return false;
+ }
+
+ // validate custom params
+ foreach (explode('-', $custom_params) as $param){
+ if(empty($param)) continue;
+
+ // extract option
+ if (str_contains($param, '=')) $param = explode('=', $param)[0];
+ else $param = rtrim($param, ' ');
+ // remove first char if first char is -
+ if ($param[0] == '-') $param = ltrim($param, $param[0]);
+
+ if (str_contains($param, ' ')) {
+ // bad char
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'bad character SPACE'
+ );
+ return false;
+ }
+
+ // check if param is whitelisted
+ if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
+ // bad option
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'bad option '. $param
+ );
+ return false;
+ }
+ }
+ if (empty($subfolder2)) {
+ $subfolder2 = "";
+ }
+ if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $maxage = "0";
+ }
+ if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $timeout1 = "600";
+ }
+ if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $timeout2 = "600";
+ }
+ if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
+ $maxbytespersecond = "0";
+ }
+ if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ if (@preg_match("/" . $exclude . "/", null) === false) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT '1' FROM `imapsync`
+ WHERE `user2` = :user2 AND `user1` = :user1 AND `source_id` = :source_id");
+ $stmt->execute(array(':user1' => $user1, ':user2' => $username, ':source_id' => $source_id));
+ $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
+ if ($num_results != 0) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => array('object_exists', htmlspecialchars($source['name'] . ' / ' . $user1))
+ );
+ return false;
+ }
+ $stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `source_id`, `user1`, `password1`, `mins_interval`, `delete2duplicates`, `custom_params`, `active`)
+ VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :source_id, :user1, :password1, :mins_interval, :delete2duplicates, :custom_params, :active)");
+ $stmt->execute(array(
+ ':user2' => $username,
+ ':custom_params' => $custom_params,
+ ':exclude' => $exclude,
+ ':maxage' => $maxage,
+ ':delete1' => $delete1,
+ ':delete2' => $delete2,
+ ':timeout1' => $timeout1,
+ ':timeout2' => $timeout2,
+ ':automap' => $automap,
+ ':skipcrossduplicates' => $skipcrossduplicates,
+ ':maxbytespersecond' => $maxbytespersecond,
+ ':subscribeall' => $subscribeall,
+ ':dry' => $dry,
+ ':subfolder2' => $subfolder2,
+ ':source_id' => $source_id,
+ ':user1' => $user1,
+ ':password1' => $password1,
+ ':mins_interval' => $mins_interval,
+ ':delete2duplicates' => $delete2duplicates,
+ ':active' => $active,
+ ));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => array('mailbox_modified', $username)
+ );
+ break;
+ case 'source':
+ $role = $_SESSION['mailcow_cc_role'] ?? null;
+ if (!in_array($role, array('admin', 'domainadmin', 'user'))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $name = trim($_data['name'] ?? '');
+ $description = trim($_data['description'] ?? '');
+ $host1 = strtolower(trim($_data['host1'] ?? ''));
+ $port1 = intval($_data['port1'] ?? 0);
+ $enc1 = $_data['enc1'] ?? 'TLS';
+ $auth_type = $_data['auth_type'] ?? 'PLAIN';
+ $active = isset($_data['active']) ? intval($_data['active']) : 1;
+
+ // Creator owns the row; visibility is driven by scope + target tables.
+ $created_by = $_SESSION['mailcow_cc_username'];
+ $scope = $_data['scope'] ?? 'user';
+ if ($role == 'user') $scope = 'user';
+ $scope_domains = (array)($_data['domains'] ?? array());
+ $scope_users = (array)($_data['users'] ?? array());
+
+ if ($name === '' || strlen($name) > 100) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_name_invalid'
+ );
+ return false;
+ }
+ if ($host1 === '') {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_host_invalid'
+ );
+ return false;
+ }
+ if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_port_invalid'
+ );
+ return false;
+ }
+ if (!in_array($enc1, array('TLS', 'SSL', 'PLAIN'))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_enc_invalid'
+ );
+ return false;
+ }
+ if (!in_array($auth_type, array('PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_auth_invalid'
+ );
+ return false;
+ }
+
+ $oauth_flow = 'client_credentials';
+ $oauth_token_endpoint = null;
+ $oauth_authorize_endpoint = null;
+ $oauth_userinfo_endpoint = null;
+ $oauth_client_id = null;
+ $oauth_client_secret = null;
+ $oauth_scope = null;
+ $oauth_extra_params = null;
+ if ($auth_type == 'XOAUTH2') {
+ $oauth_flow = ($_data['oauth_flow'] ?? '') === 'authorization_code' ? 'authorization_code' : 'client_credentials';
+ $oauth_token_endpoint = trim($_data['oauth_token_endpoint'] ?? '');
+ $oauth_authorize_endpoint = trim($_data['oauth_authorize_endpoint'] ?? '');
+ $oauth_userinfo_endpoint = trim($_data['oauth_userinfo_endpoint'] ?? '');
+ $oauth_client_id = trim($_data['oauth_client_id'] ?? '');
+ $oauth_client_secret = $_data['oauth_client_secret'] ?? '';
+ $oauth_scope = trim($_data['oauth_scope'] ?? '');
+ $oauth_extra_params = trim($_data['oauth_extra_params'] ?? '');
+ if ($oauth_token_endpoint === '' || $oauth_client_id === '' || $oauth_client_secret === '' || $oauth_scope === '') {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_oauth_required'
+ );
+ return false;
+ }
+ // authorization_code additionally needs the authorize endpoint (user login redirect)
+ if ($oauth_flow == 'authorization_code' && !filter_var($oauth_authorize_endpoint, FILTER_VALIDATE_URL)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_oauth_endpoint_invalid'
+ );
+ return false;
+ }
+ if (!filter_var($oauth_token_endpoint, FILTER_VALIDATE_URL)
+ || ($oauth_userinfo_endpoint !== '' && !filter_var($oauth_userinfo_endpoint, FILTER_VALIDATE_URL))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_oauth_endpoint_invalid'
+ );
+ return false;
+ }
+ if ($oauth_extra_params !== '' && json_decode($oauth_extra_params) === null) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_oauth_extra_invalid'
+ );
+ return false;
+ }
+ if ($oauth_extra_params === '') $oauth_extra_params = null;
+ if ($oauth_authorize_endpoint === '') $oauth_authorize_endpoint = null;
+ if ($oauth_userinfo_endpoint === '') $oauth_userinfo_endpoint = null;
+ }
+
+ // Uniqueness on (created_by, name)
+ $stmt = $pdo->prepare("SELECT `id` FROM `imapsync_source`
+ WHERE `name` = :name AND `created_by` = :created_by");
+ $stmt->execute(array(':name' => $name, ':created_by' => $created_by));
+ if ($stmt->fetch(PDO::FETCH_ASSOC)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('object_exists', htmlspecialchars($name))
+ );
+ return false;
+ }
+
+ $stmt = $pdo->prepare("INSERT INTO `imapsync_source`
+ (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`,
+ `oauth_flow`, `oauth_token_endpoint`, `oauth_authorize_endpoint`, `oauth_userinfo_endpoint`,
+ `oauth_client_id`, `oauth_client_secret`, `oauth_scope`,
+ `oauth_extra_params`, `active`)
+ VALUES (:name, :description, :created_by, :scope, :host1, :port1, :enc1, :auth_type,
+ :oauth_flow, :oauth_token_endpoint, :oauth_authorize_endpoint, :oauth_userinfo_endpoint,
+ :oauth_client_id, :oauth_client_secret, :oauth_scope,
+ :oauth_extra_params, :active)");
+ $stmt->execute(array(
+ ':name' => $name,
+ ':description' => $description,
+ ':created_by' => $created_by,
+ ':scope' => in_array($scope, array('all', 'domain', 'user')) ? $scope : 'user',
+ ':host1' => $host1,
+ ':port1' => $port1,
+ ':enc1' => $enc1,
+ ':auth_type' => $auth_type,
+ ':oauth_flow' => $oauth_flow,
+ ':oauth_token_endpoint' => $oauth_token_endpoint,
+ ':oauth_authorize_endpoint' => $oauth_authorize_endpoint,
+ ':oauth_userinfo_endpoint' => $oauth_userinfo_endpoint,
+ ':oauth_client_id' => $oauth_client_id,
+ ':oauth_client_secret' => $oauth_client_secret,
+ ':oauth_scope' => $oauth_scope,
+ ':oauth_extra_params' => $oauth_extra_params,
+ ':active' => $active,
+ ));
+ $source_id = $pdo->lastInsertId();
+ if (!imapsync_source_apply_scope($source_id, $role, $scope, $scope_domains, $scope_users, $_data_log)) {
+ // Roll back the row so a rejected scope does not leave an orphan source
+ $pdo->prepare("DELETE FROM `imapsync_source` WHERE `id` = :id")->execute(array(':id' => $source_id));
+ return false;
+ }
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_added', $name)
+ );
+ return true;
+ break;
+ }
+ break;
+ case 'edit':
+ switch ($_type) {
+ case 'job':
+ if (!is_array($_data['id'])) {
+ $ids = array();
+ $ids[] = $_data['id'];
+ }
+ else {
+ $ids = $_data['id'];
+ }
+ if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ foreach ($ids as $id) {
+ $is_now = syncjob('get', 'job', $id, array('with_password'));
+ if (!empty($is_now)) {
+ $username = $is_now['user2'];
+ $user1 = (!empty($_data['user1'])) ? $_data['user1'] : $is_now['user1'];
+ $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
+ $last_run = (isset($_data['last_run'])) ? NULL : $is_now['last_run'];
+ $success = (isset($_data['success'])) ? NULL : $is_now['success'];
+ $delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates'];
+ $subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall'];
+ $dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry'];
+ $delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1'];
+ $delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2'];
+ $automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap'];
+ $skipcrossduplicates = (isset($_data['skipcrossduplicates'])) ? intval($_data['skipcrossduplicates']) : $is_now['skipcrossduplicates'];
+ $password1 = (!empty($_data['password1'])) ? $_data['password1'] : $is_now['password1'];
+ $source_id = (!empty($_data['source_id'])) ? intval($_data['source_id']) : intval($is_now['source_id']);
+ $subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2'];
+ $mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval'];
+ $exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude'];
+ $custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params'];
+ $maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage'];
+ $maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond'];
+ $timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1'];
+ $timeout2 = (isset($_data['timeout2']) && $_data['timeout2'] != "") ? intval($_data['timeout2']) : $is_now['timeout2'];
+ }
+ else {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+
+ // Resolve and authorize the (possibly switched) source. Visibility enforced by syncjob('get', 'source').
+ $source = syncjob('get', 'source', array('id' => $source_id));
+ if (empty($source) || intval($source['active']) != 1) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'imapsync_source_unavailable'
+ );
+ continue;
+ }
+ if ($source['auth_type'] == 'XOAUTH2') {
+ // Wipe any leftover password when bound to an OAuth2 source.
+ $password1 = '';
+ }
+
+ // validate custom params
+ foreach (explode('-', $custom_params) as $param){
+ if(empty($param)) continue;
+
+ // extract option
+ if (str_contains($param, '=')) $param = explode('=', $param)[0];
+ else $param = rtrim($param, ' ');
+ // remove first char if first char is -
+ if ($param[0] == '-') $param = ltrim($param, $param[0]);
+
+ if (str_contains($param, ' ')) {
+ // bad char
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'bad character SPACE'
+ );
+ return false;
+ }
+
+ // check if param is whitelisted
+ if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
+ // bad option
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'bad option '. $param
+ );
+ return false;
+ }
+ }
+ if (empty($subfolder2)) {
+ $subfolder2 = "";
+ }
+ if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $maxage = "0";
+ }
+ if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $timeout1 = "600";
+ }
+ if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
+ $timeout2 = "600";
+ }
+ if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
+ $maxbytespersecond = "0";
+ }
+ if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+ if (@preg_match("/" . $exclude . "/", null) === false) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+ $stmt = $pdo->prepare("UPDATE `imapsync` SET `delete1` = :delete1,
+ `delete2` = :delete2,
+ `automap` = :automap,
+ `skipcrossduplicates` = :skipcrossduplicates,
+ `maxage` = :maxage,
+ `maxbytespersecond` = :maxbytespersecond,
+ `subfolder2` = :subfolder2,
+ `exclude` = :exclude,
+ `source_id` = :source_id,
+ `last_run` = :last_run,
+ `success` = :success,
+ `user1` = :user1,
+ `password1` = :password1,
+ `mins_interval` = :mins_interval,
+ `delete2duplicates` = :delete2duplicates,
+ `custom_params` = :custom_params,
+ `timeout1` = :timeout1,
+ `timeout2` = :timeout2,
+ `subscribeall` = :subscribeall,
+ `dry` = :dry,
+ `active` = :active
+ WHERE `id` = :id");
+ $stmt->execute(array(
+ ':delete1' => $delete1,
+ ':delete2' => $delete2,
+ ':automap' => $automap,
+ ':skipcrossduplicates' => $skipcrossduplicates,
+ ':id' => $id,
+ ':exclude' => $exclude,
+ ':maxage' => $maxage,
+ ':maxbytespersecond' => $maxbytespersecond,
+ ':subfolder2' => $subfolder2,
+ ':source_id' => $source_id,
+ ':user1' => $user1,
+ ':password1' => $password1,
+ ':last_run' => $last_run,
+ ':success' => $success,
+ ':mins_interval' => $mins_interval,
+ ':delete2duplicates' => $delete2duplicates,
+ ':custom_params' => $custom_params,
+ ':timeout1' => $timeout1,
+ ':timeout2' => $timeout2,
+ ':subscribeall' => $subscribeall,
+ ':dry' => $dry,
+ ':active' => $active,
+ ));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => array('mailbox_modified', $username)
+ );
+ }
+ break;
+ case 'source':
+ $ids = (array)($_data['id'] ?? array());
+ foreach ($ids as $id) {
+ if (!is_numeric($id)) continue;
+ $is_now = syncjob('get', 'source', array('id' => $id, 'with_secret' => true));
+ if (empty($is_now) || !imapsync_source_can_edit_row($is_now)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+
+ $name = isset($_data['name']) ? trim($_data['name']) : $is_now['name'];
+ $description = isset($_data['description']) ? trim($_data['description']) : $is_now['description'];
+ $host1 = isset($_data['host1']) ? strtolower(trim($_data['host1'])) : $is_now['host1'];
+ $port1 = isset($_data['port1']) ? intval($_data['port1']) : $is_now['port1'];
+ $enc1 = $_data['enc1'] ?? $is_now['enc1'];
+ $auth_type = $_data['auth_type'] ?? $is_now['auth_type'];
+ $active = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
+
+ if ($name === '' || strlen($name) > 100 || $host1 === ''
+ || !filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))
+ || !in_array($enc1, array('TLS', 'SSL', 'PLAIN'))
+ || !in_array($auth_type, array('PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'))) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_invalid'
+ );
+ continue;
+ }
+
+ $oauth_flow = $is_now['oauth_flow'];
+ $oauth_authorize_endpoint = $is_now['oauth_authorize_endpoint'];
+ $oauth_userinfo_endpoint = $is_now['oauth_userinfo_endpoint'];
+ if ($auth_type == 'XOAUTH2') {
+ $oauth_flow = ($_data['oauth_flow'] ?? $is_now['oauth_flow']) === 'authorization_code' ? 'authorization_code' : 'client_credentials';
+ $oauth_token_endpoint = isset($_data['oauth_token_endpoint']) ? trim($_data['oauth_token_endpoint']) : $is_now['oauth_token_endpoint'];
+ $oauth_authorize_endpoint = isset($_data['oauth_authorize_endpoint']) ? trim($_data['oauth_authorize_endpoint']) : $is_now['oauth_authorize_endpoint'];
+ $oauth_userinfo_endpoint = isset($_data['oauth_userinfo_endpoint']) ? trim($_data['oauth_userinfo_endpoint']) : $is_now['oauth_userinfo_endpoint'];
+ // Empty client_secret on edit keeps the stored one (avoids accidental wipes from masked fields)
+ $oauth_client_id = isset($_data['oauth_client_id']) ? trim($_data['oauth_client_id']) : $is_now['oauth_client_id'];
+ $oauth_client_secret = (!empty($_data['oauth_client_secret'])) ? $_data['oauth_client_secret'] : $is_now['oauth_client_secret'];
+ $oauth_scope = isset($_data['oauth_scope']) ? trim($_data['oauth_scope']) : $is_now['oauth_scope'];
+ $oauth_extra_params = isset($_data['oauth_extra_params']) ? trim($_data['oauth_extra_params']) : $is_now['oauth_extra_params'];
+ if ($oauth_extra_params === '') $oauth_extra_params = null;
+ if (empty($oauth_token_endpoint) || empty($oauth_client_id) || empty($oauth_client_secret) || empty($oauth_scope)
+ || !filter_var($oauth_token_endpoint, FILTER_VALIDATE_URL)
+ || ($oauth_flow == 'authorization_code' && !filter_var($oauth_authorize_endpoint, FILTER_VALIDATE_URL))
+ || (!empty($oauth_userinfo_endpoint) && !filter_var($oauth_userinfo_endpoint, FILTER_VALIDATE_URL))
+ || ($oauth_extra_params !== null && json_decode($oauth_extra_params) === null)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'imapsync_source_oauth_invalid'
+ );
+ continue;
+ }
+ if (empty($oauth_authorize_endpoint)) $oauth_authorize_endpoint = null;
+ if (empty($oauth_userinfo_endpoint)) $oauth_userinfo_endpoint = null;
+ } else {
+ // Wipe OAuth fields when switching away from XOAUTH2
+ $oauth_flow = 'client_credentials';
+ $oauth_token_endpoint = null;
+ $oauth_authorize_endpoint = null;
+ $oauth_userinfo_endpoint = null;
+ $oauth_client_id = null;
+ $oauth_client_secret = null;
+ $oauth_scope = null;
+ $oauth_extra_params = null;
+ }
+
+ $stmt = $pdo->prepare("UPDATE `imapsync_source` SET
+ `name` = :name, `description` = :description, `host1` = :host1,
+ `port1` = :port1, `enc1` = :enc1, `auth_type` = :auth_type,
+ `oauth_flow` = :oauth_flow,
+ `oauth_token_endpoint` = :oauth_token_endpoint,
+ `oauth_authorize_endpoint` = :oauth_authorize_endpoint,
+ `oauth_userinfo_endpoint` = :oauth_userinfo_endpoint,
+ `oauth_client_id` = :oauth_client_id,
+ `oauth_client_secret` = :oauth_client_secret,
+ `oauth_scope` = :oauth_scope,
+ `oauth_extra_params` = :oauth_extra_params,
+ `active` = :active
+ WHERE `id` = :id");
+ $stmt->execute(array(
+ ':name' => $name,
+ ':description' => $description,
+ ':host1' => $host1,
+ ':port1' => $port1,
+ ':enc1' => $enc1,
+ ':auth_type' => $auth_type,
+ ':oauth_flow' => $oauth_flow,
+ ':oauth_token_endpoint' => $oauth_token_endpoint,
+ ':oauth_authorize_endpoint' => $oauth_authorize_endpoint,
+ ':oauth_userinfo_endpoint' => $oauth_userinfo_endpoint,
+ ':oauth_client_id' => $oauth_client_id,
+ ':oauth_client_secret' => $oauth_client_secret,
+ ':oauth_scope' => $oauth_scope,
+ ':oauth_extra_params' => $oauth_extra_params,
+ ':active' => $active,
+ ':id' => $id,
+ ));
+ // Scope/targets are editable; only touched when the request carries `scope`.
+ // apply_scope also (re)writes the scope column, incl. the client_credentials guardrail.
+ if (isset($_data['scope'])) {
+ $scope = ($role == 'user') ? 'user' : $_data['scope'];
+ if (!imapsync_source_apply_scope($id, $role, $scope,
+ (array)($_data['domains'] ?? array()), (array)($_data['users'] ?? array()), $_data_log)) {
+ continue;
+ }
+ }
+ // Guardrail also on edit: switching to client_credentials must never leave a shared scope
+ // (covers the case where the request changed the flow but did not carry `scope`).
+ if ($auth_type == 'XOAUTH2' && $oauth_flow == 'client_credentials') {
+ $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id")->execute(array(':id' => $id));
+ $pdo->prepare("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id")->execute(array(':id' => $id));
+ $pdo->prepare("UPDATE `imapsync_source` SET `scope` = 'user' WHERE `id` = :id")->execute(array(':id' => $id));
+ }
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_modified', $name)
+ );
+ }
+ return true;
+ break;
+ case 'refresh_token':
+ $id = is_array($_data) ? ($_data['id'] ?? null) : $_data;
+ if (is_array($id)) $id = reset($id);
+ if (!is_numeric($id)) return false;
+ $is_now = syncjob('get', 'source', array('id' => $id, 'with_secret' => true));
+ if (empty($is_now) || !imapsync_source_can_edit_row($is_now)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $ok = imapsync_source_refresh_token_internal($id);
+ if ($ok) {
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_token_refreshed', $is_now['name'])
+ );
+ } else {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_token_refresh_failed', $is_now['name'])
+ );
+ }
+ return $ok;
+ break;
+ }
+ break;
+ case 'delete':
+ switch ($_type) {
+ case 'job':
+ if (!is_array($_data['id'])) {
+ $ids = array();
+ $ids[] = $_data['id'];
+ }
+ else {
+ $ids = $_data['id'];
+ }
+ if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ foreach ($ids as $id) {
+ if (!is_numeric($id)) {
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT `user2` FROM `imapsync` WHERE id = :id");
+ $stmt->execute(array(':id' => $id));
+ $user2 = $stmt->fetch(PDO::FETCH_ASSOC)['user2'];
+ if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $user2)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+ $stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id");
+ $stmt->execute(array(':id' => $id));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+ 'msg' => array('deleted_syncjob', $id)
+ );
+ }
+ break;
+ case 'source':
+ $ids = (array)($_data['id'] ?? array());
+ foreach ($ids as $id) {
+ if (!is_numeric($id)) continue;
+ $is_now = syncjob('get', 'source', array('id' => $id));
+ if (empty($is_now) || !imapsync_source_can_edit_row($is_now)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ continue;
+ }
+ // FK ON DELETE RESTRICT prevents removal while syncjobs reference it; emit a clean message
+ $ref = $pdo->prepare("SELECT COUNT(*) AS c FROM `imapsync` WHERE `source_id` = :id");
+ $ref->execute(array(':id' => $id));
+ if (intval($ref->fetch(PDO::FETCH_ASSOC)['c']) > 0) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_in_use', $is_now['name'])
+ );
+ continue;
+ }
+ $stmt = $pdo->prepare("DELETE FROM `imapsync_source` WHERE `id` = :id");
+ $stmt->execute(array(':id' => $id));
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_source_deleted', $is_now['name'])
+ );
+ }
+ return true;
+ break;
+ }
+ break;
+ case 'get':
+ switch ($_type) {
+ case 'job':
+ $syncjobdetails = array();
+ if (!is_numeric($_data)) {
+ return false;
+ }
+ // Join imapsync_source so the UI gets host/auth-type/creator of the bound source for display,
+ // plus token-status fields for XOAUTH2 sources.
+ $source_select = "s.`name` AS source_name, s.`description` AS source_description,
+ s.`host1` AS source_host, s.`port1` AS source_port, s.`enc1` AS source_enc,
+ 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.`oauth_token_expires` AS source_oauth_token_expires,
+ s.`oauth_last_refresh_error` AS source_oauth_last_refresh_error";
+ if (isset($_extra) && in_array('no_log', $_extra)) {
+ $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("returned_text", "password1")');
+ $fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
+ while($field = array_shift($fields)) {
+ $shown_fields[] = 'i.`' . $field['Field'] . '`';
+ }
+ $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ", $source_select
+ FROM `imapsync` i LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id`
+ WHERE i.`id` = :id");
+ }
+ elseif (isset($_extra) && in_array('with_password', $_extra)) {
+ $stmt = $pdo->prepare("SELECT i.*, $source_select
+ FROM `imapsync` i LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id`
+ WHERE i.`id` = :id");
+ }
+ else {
+ $field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("password1")');
+ $fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
+ while($field = array_shift($fields)) {
+ $shown_fields[] = 'i.`' . $field['Field'] . '`';
+ }
+ $stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ", $source_select
+ FROM `imapsync` i LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id`
+ WHERE i.`id` = :id");
+ }
+ $stmt->execute(array(':id' => $_data));
+ $syncjobdetails = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!empty($syncjobdetails['returned_text'])) {
+ $syncjobdetails['log'] = $syncjobdetails['returned_text'];
+ }
+ else {
+ $syncjobdetails['log'] = '';
+ }
+ unset($syncjobdetails['returned_text']);
+ if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $syncjobdetails['user2'])) {
+ return false;
+ }
+ return $syncjobdetails;
+ break;
+ case 'jobs':
+ $syncjobdata = array();
+ if (isset($_data) && filter_var($_data, FILTER_VALIDATE_EMAIL)) {
+ if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
+ return false;
+ }
+ }
+ else {
+ $_data = $_SESSION['mailcow_cc_username'];
+ }
+ $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username");
+ $stmt->execute(array(':username' => $_data));
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ while($row = array_shift($rows)) {
+ $syncjobdata[] = $row['id'];
+ }
+ return $syncjobdata;
+ break;
+ case 'source':
+ $id = is_array($_data) ? ($_data['id'] ?? null) : $_data;
+ $with_secret = is_array($_data) && !empty($_data['with_secret']);
+ if (!is_numeric($id)) return false;
+ $params = array(':id' => $id);
+ $visibility = imapsync_source_visible_sql($params);
+ $stmt = $pdo->prepare("SELECT s.* FROM `imapsync_source` s WHERE s.`id` = :id AND " . $visibility);
+ $stmt->execute($params);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row) return false;
+ // Attach scope targets for the UI (visibility column + edit form pre-fill)
+ $dstmt = $pdo->prepare("SELECT `domain` FROM `imapsync_source_domain` WHERE `source_id` = :id ORDER BY `domain`");
+ $dstmt->execute(array(':id' => $id));
+ $row['domains'] = $dstmt->fetchAll(PDO::FETCH_COLUMN);
+ $ustmt = $pdo->prepare("SELECT `username` FROM `imapsync_source_user` WHERE `source_id` = :id ORDER BY `username`");
+ $ustmt->execute(array(':id' => $id));
+ $row['users'] = $ustmt->fetchAll(PDO::FETCH_COLUMN);
+ // Let the UI hide edit/delete for rows the session may not manage (server enforces too)
+ $row['can_edit'] = imapsync_source_can_edit_row($row);
+ if (!$with_secret) {
+ // Never leak secrets / token cache to the UI consumer that does not explicitly request them
+ $row['oauth_client_secret'] = '';
+ $row['oauth_access_token'] = '';
+ }
+ return $row;
+ break;
+ case 'sources':
+ // Returns a list of ids the current session may see. Used by UI tables / dropdowns.
+ $params = array();
+ $visibility = imapsync_source_visible_sql($params);
+ $stmt = $pdo->prepare("SELECT s.`id` FROM `imapsync_source` s WHERE " . $visibility . " ORDER BY FIELD(s.`scope`, 'all', 'domain', 'user'), s.`name`");
+ $stmt->execute($params);
+ $ids = array();
+ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ $ids[] = $row['id'];
+ }
+ return $ids;
+ break;
+ }
+ break;
+ }
+ return false;
+}
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index 72018a6bc..2046fb75e 100644
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@ -4,7 +4,7 @@ function init_db_schema()
try {
global $pdo;
- $db_version = "19022026_1220";
+ $db_version = "15072026_1000";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -754,24 +754,133 @@ function init_db_schema()
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
+ "imapsync_source" => array(
+ "cols" => array(
+ "id" => "INT NOT NULL AUTO_INCREMENT",
+ "name" => "VARCHAR(100) NOT NULL",
+ "description" => "VARCHAR(255) NOT NULL DEFAULT ''",
+ "created_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
+ "scope" => "ENUM('all','domain','user') NOT NULL DEFAULT 'user'",
+ "host1" => "VARCHAR(255) NOT NULL",
+ "port1" => "SMALLINT UNSIGNED NOT NULL",
+ "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
+ "auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
+ "oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
+ "oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_authorize_endpoint" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_userinfo_endpoint" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_client_id" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_client_secret" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_scope" => "VARCHAR(500) DEFAULT NULL",
+ "oauth_extra_params" => "TEXT DEFAULT NULL",
+ "oauth_access_token" => "TEXT DEFAULT NULL",
+ "oauth_token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
+ "oauth_last_refresh_error" => "TEXT DEFAULT NULL",
+ "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
+ "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
+ "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
+ ),
+ "keys" => array(
+ "primary" => array(
+ "" => array("id")
+ ),
+ "unique" => array(
+ "uniq_creator_name" => array("created_by", "name")
+ ),
+ "key" => array(
+ "idx_created_by" => array("created_by"),
+ "idx_scope" => array("scope")
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
+ "imapsync_source_domain" => array(
+ "cols" => array(
+ "source_id" => "INT NOT NULL",
+ "domain" => "VARCHAR(255) NOT NULL"
+ ),
+ "keys" => array(
+ "primary" => array(
+ "" => array("source_id", "domain")
+ ),
+ "key" => array(
+ "idx_domain" => array("domain")
+ ),
+ "fkey" => array(
+ "fk_imapsync_source_domain" => array(
+ "col" => "source_id",
+ "ref" => "imapsync_source.id",
+ "delete" => "CASCADE",
+ "update" => "NO ACTION"
+ )
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
+ "imapsync_source_user" => array(
+ "cols" => array(
+ "source_id" => "INT NOT NULL",
+ "username" => "VARCHAR(255) NOT NULL"
+ ),
+ "keys" => array(
+ "primary" => array(
+ "" => array("source_id", "username")
+ ),
+ "key" => array(
+ "idx_username" => array("username")
+ ),
+ "fkey" => array(
+ "fk_imapsync_source_user" => array(
+ "col" => "source_id",
+ "ref" => "imapsync_source.id",
+ "delete" => "CASCADE",
+ "update" => "NO ACTION"
+ )
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
+ "imapsync_source_oauth_token" => array(
+ "cols" => array(
+ "source_id" => "INT NOT NULL",
+ "username" => "VARCHAR(255) NOT NULL",
+ "access_token" => "TEXT DEFAULT NULL",
+ "refresh_token" => "TEXT DEFAULT NULL",
+ "token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
+ "last_refresh_error" => "TEXT DEFAULT NULL",
+ "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
+ "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
+ ),
+ "keys" => array(
+ "primary" => array(
+ "" => array("source_id", "username")
+ ),
+ "fkey" => array(
+ "fk_imapsync_source_oauth_token" => array(
+ "col" => "source_id",
+ "ref" => "imapsync_source.id",
+ "delete" => "CASCADE",
+ "update" => "NO ACTION"
+ )
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
"imapsync" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
"user2" => "VARCHAR(255) NOT NULL",
- "host1" => "VARCHAR(255) NOT NULL",
- "authmech1" => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT 'PLAIN'",
+ "source_id" => "INT NOT NULL",
"regextrans2" => "VARCHAR(255) DEFAULT ''",
"authmd51" => "TINYINT(1) NOT NULL DEFAULT 0",
"domain2" => "VARCHAR(255) NOT NULL DEFAULT ''",
"subfolder2" => "VARCHAR(255) NOT NULL DEFAULT ''",
"user1" => "VARCHAR(255) NOT NULL",
- "password1" => "VARCHAR(255) NOT NULL",
+ "password1" => "VARCHAR(255) NOT NULL DEFAULT ''",
"exclude" => "VARCHAR(500) NOT NULL DEFAULT ''",
"maxage" => "SMALLINT NOT NULL DEFAULT '0'",
"mins_interval" => "SMALLINT UNSIGNED NOT NULL DEFAULT '0'",
"maxbytespersecond" => "VARCHAR(50) NOT NULL DEFAULT '0'",
- "port1" => "SMALLINT UNSIGNED NOT NULL",
- "enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
"delete2duplicates" => "TINYINT(1) NOT NULL DEFAULT '1'",
"delete1" => "TINYINT(1) NOT NULL DEFAULT '0'",
"delete2" => "TINYINT(1) NOT NULL DEFAULT '0'",
@@ -794,6 +903,17 @@ function init_db_schema()
"keys" => array(
"primary" => array(
"" => array("id")
+ ),
+ "key" => array(
+ "idx_source_id" => array("source_id")
+ ),
+ "fkey" => array(
+ "fk_imapsync_source" => array(
+ "col" => "source_id",
+ "ref" => "imapsync_source.id",
+ "delete" => "RESTRICT",
+ "update" => "NO ACTION"
+ )
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
@@ -1183,6 +1303,33 @@ function init_db_schema()
}
}
+ // Migrate imapsync to source-based schema
+ if ($table == 'imapsync') {
+ $stmt = $pdo->query("SHOW TABLES LIKE 'imapsync'");
+ if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0) {
+ $stmt = $pdo->query("SHOW COLUMNS FROM `imapsync` LIKE 'host1'");
+ $has_legacy_cols = (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0);
+ $stmt = $pdo->query("SHOW COLUMNS FROM `imapsync` LIKE 'source_id'");
+ $has_source_id = (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0);
+
+ if ($has_legacy_cols && !$has_source_id) {
+ $pdo->exec("INSERT IGNORE INTO `imapsync_source` (`name`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
+ SELECT CONCAT('legacy-', `host1`, '-', `port1`, '-', `enc1`) AS name,
+ '' AS created_by, 'all' AS scope, `host1`, `port1`, `enc1`, `authmech1`, 1
+ FROM `imapsync`
+ GROUP BY `host1`, `port1`, `enc1`, `authmech1`");
+ $pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `source_id` INT DEFAULT NULL");
+ $pdo->exec("UPDATE `imapsync` i
+ JOIN `imapsync_source` s
+ ON s.`host1` = i.`host1` AND s.`port1` = i.`port1`
+ AND s.`enc1` = i.`enc1` AND s.`auth_type` = i.`authmech1`
+ AND s.`created_by` = '' AND s.`scope` = 'all'
+ SET i.`source_id` = s.`id`");
+ $pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `source_id` INT NOT NULL");
+ }
+ }
+ }
+
$stmt = $pdo->query("SHOW TABLES LIKE '" . $table . "'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
if ($num_results != 0) {
diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php
index 5e57a4d41..65f7c5434 100644
--- a/data/web/inc/prerequisites.inc.php
+++ b/data/web/inc/prerequisites.inc.php
@@ -285,6 +285,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.domain_admin.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fail2ban.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fwdhost.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.mailbox.inc.php';
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.syncjob.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.mailq.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.oauth2.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.policy.inc.php';
diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php
index cec402a96..44ba2a758 100644
--- a/data/web/inc/vars.inc.php
+++ b/data/web/inc/vars.inc.php
@@ -286,7 +286,6 @@ $IMAPSYNC_OPTIONS = array(
'abort',
'authmd51',
'authmd52',
- 'authmech1',
'authmech2',
'authuser1',
'authuser2',
@@ -358,9 +357,7 @@ $IMAPSYNC_OPTIONS = array(
'notls2',
'nouidexpunge2',
'nousecache',
- 'oauthaccesstoken1',
'oauthaccesstoken2',
- 'oauthdirect1',
'oauthdirect2',
'office1',
'office2',
diff --git a/data/web/js/build/013-mailcow.js b/data/web/js/build/013-mailcow.js
index e4893d7ac..7428778a1 100644
--- a/data/web/js/build/013-mailcow.js
+++ b/data/web/js/build/013-mailcow.js
@@ -414,3 +414,134 @@ function copyToClipboard(id) {
navigator.clipboard.writeText(copyText.value);
mailcow_alert_box(lang.copy_to_clipboard, "success");
}
+
+// ===== IMAP sync sources: shared helpers + delegated listeners =====
+// Used by the mailbox/user/edit pages (each still owns its own draw_imapsync_source_table).
+// Kept here because 013-mailcow.js is loaded globally, so the logic lives in one place.
+
+// Human-readable visibility label for a source row (used by the per-page source tables)
+function imapsyncScopeDisplay(item) {
+ if (item.scope === 'all') return ' ' + lang.syncjobs.source_scope_all;
+ if (item.scope === 'domain') return escapeHtml(lang.syncjobs.source_scope_domain + ': ' + (item.domains || []).join(', '));
+ var us = item.users || [];
+ if (us.length === 0) return escapeHtml(item.created_by || '');
+ return escapeHtml(lang.syncjobs.source_scope_user + ': ' + us.join(', '));
+}
+
+// (Re)build every imapsync-source dropdown from the ACL-filtered API (fresh on modal open)
+function populateImapsyncSourceSelects() {
+ $.get("/api/v1/get/syncjob_source/all", function(sources) {
+ $('select.imapsync-source-select').each(function() {
+ var $sel = $(this);
+ var keepId = $sel.data('current-source-id');
+ $sel.empty();
+ $sel.append('');
+ $.each(sources, function(_, src) {
+ var label = src.name + ' — ' + src.host1 + ':' + src.port1 + ' (' + src.auth_type + ')';
+ if (src.scope === 'all') label += ' [' + (lang.syncjobs ? lang.syncjobs.source_scope_all : 'all') + ']';
+ var $opt = $('').val(src.id).text(label)
+ .data('auth-type', src.auth_type).data('oauth-flow', src.oauth_flow);
+ if (keepId && parseInt(keepId) === parseInt(src.id)) $opt.attr('selected', 'selected');
+ $sel.append($opt);
+ });
+ if (typeof $sel.selectpicker === 'function') {
+ $sel.selectpicker('destroy');
+ $sel.selectpicker();
+ }
+ $sel.trigger('change');
+ });
+ });
+}
+
+// Populate a scope domain/user multiselect from the ACL-filtered endpoint (preserves existing options)
+function fillImapsyncScopeSelect($sel, url, field) {
+ $.get('/api/v1/get/' + url, function(rows) {
+ var have = {};
+ $sel.find('option').each(function() { have[$(this).val()] = true; });
+ $.each(rows, function(_, r) {
+ var v = r[field];
+ if (!v || have[v]) return;
+ $sel.append($('').val(v).text(v));
+ });
+ // Rebuild the bootstrap-select wrapper (destroy + re-init)
+ if (typeof $sel.selectpicker === 'function') {
+ $sel.selectpicker('destroy');
+ $sel.selectpicker();
+ }
+ });
+}
+
+// Source editor: OAuth block visibility + authorization_code-only fields + redirect URI
+function imapsyncSyncSourceOauthToggle($form) {
+ var isOauth = $form.find('select.imapsync-source-auth-type').val() === 'XOAUTH2';
+ var isAuthCode = $form.find('select.imapsync-source-oauth-flow').val() === 'authorization_code';
+ $form.find('.imapsync-source-oauth-block').toggle(isOauth);
+ $form.find('.imapsync-source-authcode-block').toggle(isOauth && isAuthCode);
+ $form.find('.imapsync-source-redirect-uri').val(window.location.origin + '/syncjob-oauth');
+}
+
+$(document).ready(function() {
+ // Syncjob form: toggle password row / OAuth "connect" button by the selected source's flow
+ $(document).on('change', 'select.imapsync-source-select', function() {
+ var $sel = $(this);
+ var $opt = $sel.find('option:selected');
+ var $form = $sel.closest('form');
+ var isOauth = ($opt.data('auth-type') === 'XOAUTH2');
+ var isAuthCode = isOauth && ($opt.data('oauth-flow') === 'authorization_code');
+ $form.find('.password1-row').toggle(!isOauth);
+ $form.find('input[name="password1"]').prop('required', !isOauth);
+ $form.find('.oauth-source-hint').toggle(isOauth);
+ $form.find('.imapsync-oauth-connect-row').toggle(isAuthCode);
+ });
+
+ // Source editor: OAuth block + authorization_code fields
+ $(document).on('change', 'select.imapsync-source-auth-type, select.imapsync-source-oauth-flow', function() {
+ imapsyncSyncSourceOauthToggle($(this).closest('form'));
+ });
+
+ // Source editor: scope -> show + populate the domain/user pickers
+ $(document).on('change', 'select.imapsync-source-scope', function() {
+ var $form = $(this).closest('form');
+ var scope = $(this).val();
+ $form.find('.imapsync-source-domains-row').toggle(scope === 'domain');
+ $form.find('.imapsync-source-users-row').toggle(scope === 'user');
+ if (scope === 'domain') fillImapsyncScopeSelect($form.find('.imapsync-source-domains'), 'domain/all', 'domain_name');
+ if (scope === 'user') fillImapsyncScopeSelect($form.find('.imapsync-source-users'), 'mailbox/all', 'username');
+ });
+
+ // "Connect with the provider": open the per-user OAuth popup for the selected source
+ $(document).on('click', '.imapsync-oauth-connect-btn', function() {
+ var $form = $(this).closest('form');
+ var sourceId = $form.find('select.imapsync-source-select').val();
+ if (!sourceId) return;
+ window.open('/syncjob-oauth?action=start&source_id=' + encodeURIComponent(sourceId),
+ 'syncjob_oauth', 'width=600,height=700');
+ });
+
+ // Result from the OAuth popup: fill + lock user1, mark connected (or show the error)
+ window.addEventListener('message', function(ev) {
+ if (ev.origin !== window.location.origin || !ev.data || ev.data.type !== 'syncjob_oauth') return;
+ $('.imapsync-oauth-connect-row:visible').each(function() {
+ var $form = $(this).closest('form');
+ var $status = $form.find('.imapsync-oauth-connect-status');
+ if (ev.data.ok) {
+ $form.find('input[name="user1"]').val(ev.data.user1).prop('readonly', true);
+ $status.removeClass('text-danger').addClass('text-success')
+ .text((lang.syncjobs ? lang.syncjobs.syncjob_oauth_connected : 'Connected') + ': ' + ev.data.user1);
+ } else {
+ $status.removeClass('text-success').addClass('text-danger').text(ev.data.error || 'error');
+ }
+ });
+ });
+
+ // Add-source modal opens: init scope pickers + OAuth block (auth_type may be cached-form preselected)
+ $(document).on('shown.bs.modal', '#addImapsyncSourceModal', function() {
+ $(this).find('select.imapsync-source-scope').trigger('change');
+ imapsyncSyncSourceOauthToggle($(this).find('form'));
+ });
+
+ // Add-syncjob modal opens: (re)load the source dropdown from the API
+ $(document).on('shown.bs.modal', '#addSyncJobModalAdmin, #addSyncJobModal', function() {
+ populateImapsyncSourceSelects();
+ });
+});
diff --git a/data/web/js/site/edit.js b/data/web/js/site/edit.js
index 308ec8c13..dec0c31d7 100644
--- a/data/web/js/site/edit.js
+++ b/data/web/js/site/edit.js
@@ -240,4 +240,15 @@ jQuery(function($){
// Draw Table if tab is active
onVisible("[id^=wl_policy_domain_table]", () => draw_wl_policy_domain_table());
onVisible("[id^=bl_policy_domain_table]", () => draw_bl_policy_domain_table());
+
+ // initialise imapsync-source form on load.
+ if ($('select.imapsync-source-oauth-flow').length) {
+ imapsyncSyncSourceOauthToggle($('select.imapsync-source-oauth-flow').closest('form'));
+ }
+ if ($('select.imapsync-source-scope').length) {
+ $('select.imapsync-source-scope').trigger('change');
+ }
+ if ($('select.imapsync-source-select').length) {
+ populateImapsyncSourceSelects();
+ }
});
diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js
index e8edb9940..3b5dec204 100644
--- a/data/web/js/site/mailbox.js
+++ b/data/web/js/site/mailbox.js
@@ -2173,7 +2173,7 @@ jQuery(function($){
} else {
item.exclude = '' + escapeHtml(item.exclude) + '';
}
- item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.host1) + ':' + escapeHtml(item.port1);
+ item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.source_name || '?') + ' (' + escapeHtml(item.source_host || '?') + ':' + escapeHtml(item.source_port || '?') + ')';
item.action = '
' + escapeHtml(item.exclude) + '';
}
- item.server_w_port = escapeHtml(item.user1 + '@' + item.host1 + ':' + item.port1);
+ item.server_w_port = escapeHtml(item.user1 + '@' + (item.source_name || '?') + ' (' + (item.source_host || '?') + ':' + (item.source_port || '?') + ')');
if (acl_data.syncjobs === 1) {
item.action = '