diff --git a/data/Dockerfiles/dovecot/imapsync_runner.pl b/data/Dockerfiles/dovecot/imapsync_runner.pl
index e16534f8c..7f794a111 100644
--- a/data/Dockerfiles/dovecot/imapsync_runner.pl
+++ b/data/Dockerfiles/dovecot/imapsync_runner.pl
@@ -7,16 +7,10 @@ use Data::Dumper qw(Dumper);
use IPC::Run 'run';
use File::Temp;
use Try::Tiny;
+use POSIX ();
use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT);
sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s };
-my $t = Proc::ProcessTable->new;
-my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table};
-if ($imapsync_running ge 1)
-{
- print "imapsync is active, exiting...";
- exit;
-}
sub qqw($) {
my @params = ();
@@ -35,6 +29,19 @@ sub qqw($) {
return @params;
}
+# Move a finished job to the back of the queue, keeping order_id contiguous (1..N).
+# Called only from the parent after reaping a child, so all rotations are serialized.
+sub rotate_to_back {
+ my ($dbh, $jid) = @_;
+ return unless defined $jid;
+ my ($cnt) = $dbh->selectrow_array("SELECT COUNT(*) FROM imapsync");
+ my ($old) = $dbh->selectrow_array("SELECT order_id FROM imapsync WHERE id = ?", undef, $jid);
+ return unless (defined $cnt && defined $old && $old < $cnt);
+ # close the gap left behind, then place this job last
+ $dbh->do("UPDATE imapsync SET order_id = order_id - 1 WHERE order_id > ? ORDER BY order_id ASC", undef, $old);
+ $dbh->do("UPDATE imapsync SET order_id = ? WHERE id = ?", undef, $cnt, $jid);
+}
+
$run_dir="/tmp";
$dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock';
$lock_file = $run_dir . "/imapsync_busy";
@@ -46,6 +53,16 @@ $dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, {
});
$dbh->do("UPDATE imapsync SET is_running = 0");
+# Max concurrent imapsync processes (admin-configurable, stored in MySQL). Fallback 1.
+my $max_parallel = 1;
+my ($mp) = $dbh->selectrow_array("SELECT value FROM imapsync_settings WHERE name = 'max_parallel'");
+$max_parallel = int($mp) if defined($mp) && int($mp) >= 1;
+
+# Global per-process bandwidth cap in bytes/s (0 = off). Applied to every imapsync run below.
+my $global_bw = 0;
+my ($gbw) = $dbh->selectrow_array("SELECT value FROM imapsync_settings WHERE name = 'max_bytes_per_second'");
+$global_bw = int($gbw) if defined($gbw) && int($gbw) > 0;
+
sub sig_handler {
# Send die to force exception in "run"
die "sig_handler received signal, preparing to exit...\n";
@@ -91,12 +108,36 @@ my $sth = $dbh->prepare("SELECT
UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60
OR
i.last_run IS NULL)
- ORDER BY i.last_run");
+ ORDER BY i.order_id ASC, i.id ASC");
$sth->execute();
-my $row;
+my @rows = @{$sth->fetchall_arrayref()};
+$sth->finish();
-while ($row = $sth->fetchrow_arrayref()) {
+# Fork pool: run up to $max_parallel imapsync children at once, in order_id order.
+my $active = 0;
+my %pid_job; # child pid => syncjob id, so the parent can rotate it to the back when it finishes
+foreach my $row (@rows) {
+ # Throttle: once max children are running, reap one before starting the next
+ if ($active >= $max_parallel) {
+ my $done = wait();
+ my $rc = $? >> 8;
+ $active-- if $active > 0;
+ if (defined $pid_job{$done}) {
+ rotate_to_back($dbh, $pid_job{$done}) if $rc == 0; # rotate only jobs that actually ran
+ delete $pid_job{$done};
+ }
+ }
+ my $pid = fork();
+ next if (!defined $pid);
+ if ($pid != 0) { $pid_job{$pid} = @$row[0]; $active++; next; } # parent: remember pid->job, continue
+
+ # ---- CHILD ---- own DB connection so the parent's handle stays intact across forks
+ $dbh->{InactiveDestroy} = 1;
+ $dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, {
+ mysql_auto_reconnect => 1,
+ mysql_enable_utf8mb4 => 1
+ });
$id = @$row[0];
$user1 = @$row[1];
@@ -151,7 +192,11 @@ while ($row = $sth->fetchrow_arrayref()) {
|| !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;
+ $dbh->disconnect();
+ unlink $passfile1->filename if defined $passfile1;
+ unlink $passfile2->filename if defined $passfile2;
+ # exit 75 (EX_TEMPFAIL): did not actually sync -> parent keeps its queue position (no rotate)
+ POSIX::_exit(75);
}
$tokenfile1 = File::Temp->new(TEMPLATE => $template);
binmode($tokenfile1, ":utf8");
@@ -172,6 +217,12 @@ while ($row = $sth->fetchrow_arrayref()) {
my @custom_params_a = qqw($custom_params);
my $custom_params_ref = \@custom_params_a;
+ # Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit.
+ my $eff_bw = int($maxbytespersecond);
+ if ($global_bw > 0) {
+ $eff_bw = ($eff_bw > 0 && $eff_bw < $global_bw) ? $eff_bw : $global_bw;
+ }
+
my $generated_cmds = [ "/usr/local/bin/imapsync",
"--tmpdir", "/tmp",
"--nofoldersizes",
@@ -181,7 +232,7 @@ while ($row = $sth->fetchrow_arrayref()) {
($exclude eq "" ? () : ("--exclude", $exclude)),
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
($maxage eq "0" ? () : ('--maxage', $maxage)),
- ($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)),
+ ($eff_bw <= 0 ? () : ('--maxbytespersecond', $eff_bw)),
($delete2duplicates ne "1" ? () : ('--delete2duplicates')),
($subscribeall ne "1" ? () : ('--subscribeall')),
($delete1 ne "1" ? () : ('--delete')),
@@ -231,10 +282,25 @@ while ($row = $sth->fetchrow_arrayref()) {
$update->execute();
};
+ $dbh->disconnect();
+ # Remove this child's own temp files, then _exit so END/DESTROY are skipped — this keeps
+ # the parent's LockFile lock and DB handle intact (a plain exit() would run autoclean and
+ # release the shared lock while the parent + siblings are still working).
+ unlink $passfile1->filename if defined $passfile1;
+ unlink $passfile2->filename if defined $passfile2;
+ unlink $tokenfile1->filename if defined $tokenfile1;
+ POSIX::_exit(0); # ---- end CHILD ----
+}
+# Parent: wait for all remaining children, rotating each finished job to the back
+while ((my $done = wait()) != -1) {
+ my $rc = $? >> 8;
+ if (defined $pid_job{$done}) {
+ rotate_to_back($dbh, $pid_job{$done}) if $rc == 0; # rotate only jobs that actually ran
+ delete $pid_job{$done};
+ }
}
-$sth->finish();
$dbh->disconnect();
$lockmgr->unlock($lock_file);
diff --git a/data/web/admin/system.php b/data/web/admin/system.php
index 4db40c753..b736a5368 100644
--- a/data/web/admin/system.php
+++ b/data/web/admin/system.php
@@ -116,6 +116,7 @@ $template_data = [
'ip_check' => customize('get', 'ip_check'),
'custom_login' => customize('get', 'custom_login'),
'password_complexity' => password_complexity('get'),
+ 'imapsync_settings' => imapsync_get_settings(),
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
'cors_settings' => $cors_settings,
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml
index 2b34e87a0..06ef29ea6 100644
--- a/data/web/api/openapi.yaml
+++ b/data/web/api/openapi.yaml
@@ -1615,7 +1615,7 @@ paths:
enc1: SSL
auth_type: PLAIN
active: 1
- is_global: 0
+ scope: all
msg:
- imapsync_source_added
- Example import
@@ -1659,7 +1659,7 @@ paths:
enc1: SSL
auth_type: PLAIN
active: "1"
- is_global: "0"
+ scope: all
properties:
name:
description: source name, unique per owner
@@ -1688,9 +1688,21 @@ paths:
- LOGIN
- CRAM-MD5
- XOAUTH2
+ oauth_flow:
+ description: XOAUTH2 grant type (default client_credentials)
+ type: string
+ enum:
+ - client_credentials
+ - authorization_code
oauth_token_endpoint:
description: required when auth_type is XOAUTH2 (token URL)
type: string
+ oauth_authorize_endpoint:
+ description: required when oauth_flow is authorization_code (authorize URL)
+ type: string
+ oauth_userinfo_endpoint:
+ description: optional identity endpoint for authorization_code
+ type: string
oauth_client_id:
description: required when auth_type is XOAUTH2
type: string
@@ -1706,12 +1718,25 @@ paths:
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
+ scope:
+ description: >-
+ admin/domainadmin — source visibility. client_credentials
+ XOAUTH2 sources are always forced to a private scope.
type: string
+ enum:
+ - all
+ - domain
+ - user
+ domains:
+ description: target domains when scope is domain
+ type: array
+ items:
+ type: string
+ users:
+ description: target users when scope is user
+ type: array
+ items:
+ type: string
type: object
/api/v1/add/tls-policy-map:
post:
@@ -4196,8 +4221,20 @@ paths:
- LOGIN
- CRAM-MD5
- XOAUTH2
+ oauth_flow:
+ description: XOAUTH2 grant type
+ type: string
+ enum:
+ - client_credentials
+ - authorization_code
oauth_token_endpoint:
type: string
+ oauth_authorize_endpoint:
+ description: required when oauth_flow is authorization_code
+ type: string
+ oauth_userinfo_endpoint:
+ description: optional identity endpoint for authorization_code
+ type: string
oauth_client_id:
type: string
oauth_client_secret:
@@ -4209,6 +4246,23 @@ paths:
type: string
active:
type: boolean
+ scope:
+ description: source visibility (admin/domainadmin)
+ type: string
+ enum:
+ - all
+ - domain
+ - user
+ domains:
+ description: target domains when scope is domain
+ type: array
+ items:
+ type: string
+ users:
+ description: target users when scope is user
+ type: array
+ items:
+ type: string
type: object
items:
description: list of source ids to update
@@ -4278,6 +4332,135 @@ paths:
items:
type: string
type: object
+ /api/v1/edit/syncjob/order:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - syncjob
+ - edit
+ - job_order
+ msg:
+ - imapsync_order_updated
+ - "1"
+ 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: >-
+ Move a sync job to a global queue position (admin only). Positions are
+ contiguous (1..N) across all sync jobs; setting a job to a position
+ shifts the others accordingly. The runner processes jobs in ascending
+ order, up to the configured `max_parallel` at a time, and rotates a job
+ to the back after it runs.
+ operationId: Set sync job queue position
+ summary: Set sync job queue position
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ items: "3"
+ attr:
+ order_id: "1"
+ properties:
+ attr:
+ properties:
+ order_id:
+ description: target position (1..N)
+ type: number
+ type: object
+ items:
+ description: sync job id to move
+ type: string
+ type: object
+ /api/v1/edit/imapsync_settings:
+ post:
+ responses:
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "200":
+ content:
+ application/json:
+ examples:
+ response:
+ value:
+ - log:
+ - imapsync_settings
+ - edit
+ msg:
+ - max_parallel_saved
+ 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 global sync job settings (admin only). `max_parallel` is the
+ number of imapsync processes that may run concurrently; `max_kb_per_second`
+ is a per-process bandwidth cap in KB/s (0 = unlimited), stored internally
+ in bytes/s.
+ operationId: Update sync job settings
+ summary: Update sync job settings
+ requestBody:
+ content:
+ application/json:
+ schema:
+ example:
+ attr:
+ max_parallel: "4"
+ max_kb_per_second: "2000"
+ properties:
+ attr:
+ properties:
+ max_parallel:
+ description: Max. concurrent sync processes (>= 1)
+ type: number
+ max_kb_per_second:
+ description: Bandwidth limit per process in KB/s (0 = unlimited)
+ type: number
+ type: object
+ type: object
/api/v1/edit/user-acl:
post:
responses:
@@ -6005,7 +6188,8 @@ paths:
- id: 3
name: Example import
description: ""
- owner: null
+ created_by: ""
+ scope: all
host1: imap.example.org
port1: 993
enc1: SSL
diff --git a/data/web/inc/functions.syncjob.inc.php b/data/web/inc/functions.syncjob.inc.php
index 12f048117..cb8bb7129 100644
--- a/data/web/inc/functions.syncjob.inc.php
+++ b/data/web/inc/functions.syncjob.inc.php
@@ -341,6 +341,75 @@ function imapsync_source_refresh_user_token_internal($source_id, $username) {
return true;
}
+function imapsync_get_setting($name, $default = null) {
+ // Global syncjob setting (also read by the Perl runner directly from MySQL).
+ global $pdo;
+ $stmt = $pdo->prepare("SELECT `value` FROM `imapsync_settings` WHERE `name` = :name");
+ $stmt->execute(array(':name' => $name));
+ $v = $stmt->fetchColumn();
+ return ($v === false) ? $default : $v;
+}
+
+function imapsync_get_settings() {
+ // All global syncjob settings as name => value, with defaults for keys not yet stored.
+ global $pdo;
+ $settings = array('max_parallel' => 1, 'max_bytes_per_second' => 0);
+ foreach ($pdo->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR) as $k => $v) {
+ $settings[$k] = $v;
+ }
+ return $settings;
+}
+
+function imapsync_set_setting($name, $value) {
+ // Admin-only writer for a global syncjob setting.
+ global $pdo;
+ if (($_SESSION['mailcow_cc_role'] ?? '') !== 'admin') return false;
+ $stmt = $pdo->prepare("INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value)
+ ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)");
+ $stmt->execute(array(':name' => $name, ':value' => (string)$value));
+ return true;
+}
+
+function imapsync_set_order($id, $pos) {
+ // Move syncjob $id to global queue position $pos (1..N), shifting only the rows between
+ // the old and new position (range-shift, not a full renumber). Admin-only, transactional.
+ global $pdo;
+ if (($_SESSION['mailcow_cc_role'] ?? '') !== 'admin' || !is_numeric($id)) return false;
+ $pos = intval($pos);
+ try {
+ $pdo->beginTransaction();
+ $cnt = intval($pdo->query("SELECT COUNT(*) FROM `imapsync`")->fetchColumn());
+ if ($cnt === 0) { $pdo->commit(); return false; }
+ if ($pos < 1) $pos = 1;
+ if ($pos > $cnt) $pos = $cnt;
+ $stmt = $pdo->prepare("SELECT `order_id` FROM `imapsync` WHERE `id` = :id");
+ $stmt->execute(array(':id' => $id));
+ $old = $stmt->fetchColumn();
+ if ($old === false) { $pdo->rollBack(); return false; }
+ $old = intval($old);
+ if ($old !== $pos) {
+ if ($pos < $old) {
+ // moving up: shift the [pos, old-1] band down (+1), highest first to avoid collisions
+ $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` + 1
+ WHERE `order_id` >= :pos AND `order_id` < :old ORDER BY `order_id` DESC")
+ ->execute(array(':pos' => $pos, ':old' => $old));
+ } else {
+ // moving down: shift the (old, pos] band up (-1), lowest first
+ $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` - 1
+ WHERE `order_id` > :old AND `order_id` <= :pos ORDER BY `order_id` ASC")
+ ->execute(array(':old' => $old, ':pos' => $pos));
+ }
+ $pdo->prepare("UPDATE `imapsync` SET `order_id` = :pos WHERE `id` = :id")
+ ->execute(array(':pos' => $pos, ':id' => $id));
+ }
+ $pdo->commit();
+ return true;
+ } catch (Exception $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ return false;
+ }
+}
+
function syncjob($_action, $_type, $_data = null, $_extra = null) {
global $pdo;
global $lang;
@@ -513,8 +582,9 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
);
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 = $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`, `order_id`)
+ VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :source_id, :user1, :password1, :mins_interval, :delete2duplicates, :custom_params, :active,
+ (SELECT COALESCE(MAX(o.`order_id`), 0) + 1 FROM (SELECT `order_id` FROM `imapsync`) o))");
$stmt->execute(array(
':user2' => $username,
':custom_params' => $custom_params,
@@ -723,6 +793,26 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
break;
case 'edit':
switch ($_type) {
+ case 'job_order':
+ // Set a syncjob's global queue position (admin-only). $_data = {id, order_id}
+ $id = is_array($_data) ? ($_data['id'] ?? null) : null;
+ if (is_array($id)) $id = reset($id);
+ $pos = is_array($_data) ? ($_data['order_id'] ?? null) : null;
+ if (!is_numeric($id) || !is_numeric($pos) || imapsync_set_order($id, $pos) === false) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
+ $_SESSION['return'][] = array(
+ 'type' => 'success',
+ 'log' => array(__FUNCTION__, $_action, $_type, $_data_log),
+ 'msg' => array('imapsync_order_updated', intval($pos))
+ );
+ return true;
+ break;
case 'job':
if (!is_array($_data['id'])) {
$ids = array();
@@ -1097,8 +1187,17 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
);
continue;
}
+ // Read the position first so we can close the gap in the global queue afterwards
+ $ord = $pdo->prepare("SELECT `order_id` FROM `imapsync` WHERE `id` = :id");
+ $ord->execute(array(':id' => $id));
+ $deleted_pos = $ord->fetchColumn();
$stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id");
$stmt->execute(array(':id' => $id));
+ if ($deleted_pos !== false) {
+ $pdo->prepare("UPDATE `imapsync` SET `order_id` = `order_id` - 1
+ WHERE `order_id` > :pos ORDER BY `order_id` ASC")
+ ->execute(array(':pos' => intval($deleted_pos)));
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
@@ -1206,7 +1305,7 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
else {
$_data = $_SESSION['mailcow_cc_username'];
}
- $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username");
+ $stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username ORDER BY `order_id`");
$stmt->execute(array(':username' => $_data));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) {
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index 2046fb75e..0751c511d 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 = "15072026_1000";
+ $db_version = "16072026_1000";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -892,6 +892,7 @@ function init_db_schema()
"subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'",
"dry" => "TINYINT(1) NOT NULL DEFAULT '0'",
"is_running" => "TINYINT(1) NOT NULL DEFAULT '0'",
+ "order_id" => "INT NOT NULL DEFAULT 0",
"returned_text" => "LONGTEXT",
"last_run" => "TIMESTAMP NULL DEFAULT NULL",
"success" => "TINYINT(1) UNSIGNED DEFAULT NULL",
@@ -905,7 +906,8 @@ function init_db_schema()
"" => array("id")
),
"key" => array(
- "idx_source_id" => array("source_id")
+ "idx_source_id" => array("source_id"),
+ "idx_order_id" => array("order_id")
),
"fkey" => array(
"fk_imapsync_source" => array(
@@ -918,6 +920,20 @@ function init_db_schema()
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
+ "imapsync_settings" => array(
+ "cols" => array(
+ "name" => "VARCHAR(64) NOT NULL",
+ "value" => "VARCHAR(255) NOT NULL",
+ "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
+ "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
+ ),
+ "keys" => array(
+ "primary" => array(
+ "" => array("name")
+ )
+ ),
+ "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+ ),
"bcc_maps" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
@@ -1509,6 +1525,14 @@ function init_db_schema()
// Migrate webauthn tfa
$stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')");
+ // Syncjobs: seed global settings + one-time sequential order_id for pre-existing jobs
+ $pdo->query("INSERT IGNORE INTO `imapsync_settings` (`name`, `value`) VALUES ('max_parallel', '1')");
+ if (intval($pdo->query("SELECT COUNT(*) FROM `imapsync`")->fetchColumn()) > 0
+ && intval($pdo->query("SELECT MAX(`order_id`) FROM `imapsync`")->fetchColumn()) === 0) {
+ $pdo->query("SET @r := 0");
+ $pdo->query("UPDATE `imapsync` SET `order_id` = (@r := @r + 1) ORDER BY `id`");
+ }
+
// Inject admin if not exists
$stmt = $pdo->query("SELECT NULL FROM `admin`");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js
index 3b5dec204..f715b53a5 100644
--- a/data/web/js/site/mailbox.js
+++ b/data/web/js/site/mailbox.js
@@ -2157,7 +2157,7 @@ jQuery(function($){
"tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
- order: [[2, 'desc']],
+ order: [[3, 'asc']],
initComplete: function(){
hideTableExpandCollapseBtn('#tab-syncjobs', '#sync_job_table');
},
@@ -2228,6 +2228,12 @@ jQuery(function($){
responsivePriority: 3,
defaultContent: ''
},
+ {
+ title: lang.syncjobs.order_id,
+ data: 'order_id',
+ responsivePriority: 3,
+ defaultContent: ''
+ },
{
title: lang.owner,
data: 'user2',
diff --git a/data/web/js/site/user.js b/data/web/js/site/user.js
index 26cc821fc..77064f8ec 100644
--- a/data/web/js/site/user.js
+++ b/data/web/js/site/user.js
@@ -272,6 +272,7 @@ jQuery(function($){
"tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
+ order: [[3, 'asc']],
ajax: {
type: "GET",
url: '/api/v1/get/syncjobs/' + encodeURIComponent(mailcow_cc_username) + '/no_log',
@@ -345,6 +346,12 @@ jQuery(function($){
defaultContent: '',
responsivePriority: 3
},
+ {
+ title: lang.syncjobs.order_id,
+ data: 'order_id',
+ defaultContent: '',
+ responsivePriority: 3
+ },
{
title: 'Server',
data: 'server_w_port',
diff --git a/data/web/json_api.php b/data/web/json_api.php
index 6c20fa1de..912fa3cc3 100644
--- a/data/web/json_api.php
+++ b/data/web/json_api.php
@@ -2001,7 +2001,29 @@ if (isset($_GET['query'])) {
}
break;
case "syncjob":
- process_edit_return(syncjob('edit', 'job', array_merge(array('id' => $items), $attr)));
+ switch ($object) {
+ case "order":
+ process_edit_return(syncjob('edit', 'job_order', array_merge(array('id' => $items), $attr)));
+ break;
+ default:
+ process_edit_return(syncjob('edit', 'job', array_merge(array('id' => $items), $attr)));
+ break;
+ }
+ break;
+ case "imapsync_settings":
+ $mp = intval($attr['max_parallel'] ?? 0);
+ // UI sends KB/s; store bytes/s (runner + per-job field are bytes/s)
+ $bw = max(0, intval($attr['max_kb_per_second'] ?? 0)) * 1024;
+ if ($mp < 1) {
+ $_SESSION['return'][] = array('type' => 'danger', 'log' => array('imapsync_settings', 'edit', $attr), 'msg' => 'imapsync_max_parallel_invalid');
+ process_edit_return(false);
+ } elseif (imapsync_set_setting('max_parallel', $mp) && imapsync_set_setting('max_bytes_per_second', $bw)) {
+ $_SESSION['return'][] = array('type' => 'success', 'log' => array('imapsync_settings', 'edit'), 'msg' => 'max_parallel_saved');
+ process_edit_return(true);
+ } else {
+ $_SESSION['return'][] = array('type' => 'danger', 'log' => array('imapsync_settings', 'edit'), 'msg' => 'access_denied');
+ process_edit_return(false);
+ }
break;
case "syncjob_source":
switch ($object) {
diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json
index 9db362a5b..60d84eaa5 100644
--- a/data/web/lang/lang.de-de.json
+++ b/data/web/lang/lang.de-de.json
@@ -397,7 +397,12 @@
"logo_dark_label": "Invertiert für den Darkmode",
"logo_normal_label": "Normal",
"user_link": "Nutzer-Link",
- "filter": "Filter"
+ "filter": "Filter",
+ "syncjobs": "Sync Jobs",
+ "syncjob_max_parallel": "Max. parallele Sync-Prozesse",
+ "syncjob_max_parallel_info": "Wie viele Sync-Jobs gleichzeitig laufen dürfen.",
+ "syncjob_max_kb_per_second": "Bandbreiten-Limit pro Prozess (KB/s)",
+ "syncjob_max_kb_per_second_info": "0 = unbegrenzt. Kappt jeden Sync-Prozess; ein per-Job-Wert wird hierdurch begrenzt. Gesamt ≈ dieser Wert × parallele Prozesse."
},
"danger": {
"access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
@@ -559,7 +564,8 @@
"imapsync_source_oauth_endpoint_invalid": "Ungültige OAuth2-Endpoint-URL",
"imapsync_source_oauth_extra_invalid": "OAuth2-Extra-Parameter müssen gültiges JSON sein",
"imapsync_source_invalid": "Ungültige Daten der Sync-Quelle",
- "imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration"
+ "imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration",
+ "imapsync_max_parallel_invalid": "Anzahl paralleler Prozesse muss mindestens 1 sein"
},
"datatables": {
"collapse_all": "Alle Einklappen",
@@ -1188,7 +1194,9 @@
"imapsync_source_added": "Sync-Quelle %s wurde hinzugefügt",
"imapsync_source_modified": "Sync-Quelle %s wurde gespeichert",
"imapsync_source_deleted": "Sync-Quelle %s wurde gelöscht",
- "imapsync_source_token_refreshed": "OAuth-Token für %s wurde erneuert"
+ "imapsync_source_token_refreshed": "OAuth-Token für %s wurde erneuert",
+ "max_parallel_saved": "Sync-Job-Einstellungen gespeichert",
+ "imapsync_order_updated": "Sync-Job auf Position %s verschoben"
},
"tfa": {
"authenticators": "Authentikatoren",
@@ -1282,7 +1290,10 @@
"timeout2": "Timeout für Verbindung zum lokalen Host",
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
"custom_params": "Eigene Parameter",
- "custom_params_hint": "Richtig: --param=xy, falsch: --param xy"
+ "custom_params_hint": "Richtig: --param=xy, falsch: --param xy",
+ "order_id": "Position",
+ "order_id_hint": "Kleinere Position läuft zuerst; Ändern sortiert die globale Warteschlange um.",
+ "set_position": "Position setzen"
},
"user": {
"action": "Aktion",
diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json
index eed99ae4f..db13344b0 100644
--- a/data/web/lang/lang.en-gb.json
+++ b/data/web/lang/lang.en-gb.json
@@ -397,7 +397,12 @@
"user_quicklink": "Hide Quicklink to User Login Page",
"validate_license_now": "Validate GUID against license server",
"verify": "Verify",
- "yes": "✓"
+ "yes": "✓",
+ "syncjobs": "Sync jobs",
+ "syncjob_max_parallel": "Max. parallel sync processes",
+ "syncjob_max_parallel_info": "How many sync jobs may run at the same time.",
+ "syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)",
+ "syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process; a per-job value is capped by this. Total ≈ this × max parallel."
},
"danger": {
"access_denied": "Access denied or invalid form data",
@@ -559,7 +564,8 @@
"imapsync_source_oauth_endpoint_invalid": "Invalid OAuth2 endpoint URL",
"imapsync_source_oauth_extra_invalid": "OAuth2 extra parameters must be valid JSON",
"imapsync_source_invalid": "Invalid sync source data",
- "imapsync_source_oauth_invalid": "Invalid OAuth2 configuration"
+ "imapsync_source_oauth_invalid": "Invalid OAuth2 configuration",
+ "imapsync_max_parallel_invalid": "Parallel process count must be at least 1"
},
"datatables": {
"collapse_all": "Collapse All",
@@ -1195,7 +1201,9 @@
"imapsync_source_added": "Sync source %s has been added",
"imapsync_source_modified": "Sync source %s has been saved",
"imapsync_source_deleted": "Sync source %s has been deleted",
- "imapsync_source_token_refreshed": "OAuth token for %s has been refreshed"
+ "imapsync_source_token_refreshed": "OAuth token for %s has been refreshed",
+ "max_parallel_saved": "Sync job settings saved",
+ "imapsync_order_updated": "Sync job moved to position %s"
},
"tfa": {
"authenticators": "Authenticators",
@@ -1289,7 +1297,10 @@
"timeout2": "Timeout for connection to local host",
"maxbytespersecond": "Max. bytes per second
(0 = unlimited)",
"custom_params": "Custom parameters",
- "custom_params_hint": "Right: --param=xy, wrong: --param xy"
+ "custom_params_hint": "Right: --param=xy, wrong: --param xy",
+ "order_id": "Position",
+ "order_id_hint": "Lower position runs first; setting it re-sorts the global queue.",
+ "set_position": "Set position"
},
"user": {
"action": "Action",
diff --git a/data/web/templates/admin.twig b/data/web/templates/admin.twig
index 651107ccf..0c487c4ea 100644
--- a/data/web/templates/admin.twig
+++ b/data/web/templates/admin.twig
@@ -22,6 +22,7 @@