mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-07-18 06:24:54 +00:00
[Imapsync] Add parallel imapsync support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
+192
-8
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+23
-1
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <br><small>(0 = unlimited)</small>",
|
||||
"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",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-f2b" aria-selected="false" aria-controls="tab-config-f2b" role="tab" data-bs-toggle="tab">{{ lang.admin.f2b_parameters }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-quarantine" aria-selected="false" aria-controls="tab-config-quarantine" role="tab" data-bs-toggle="tab">{{ lang.admin.quarantine }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-quota" aria-selected="false" aria-controls="tab-config-quota" role="tab" data-bs-toggle="tab">{{ lang.admin.quota_notifications }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-syncjob" aria-selected="false" aria-controls="tab-config-syncjob" role="tab" data-bs-toggle="tab">{{ lang.admin.syncjobs }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-rsettings" aria-selected="false" aria-controls="tab-config-rsettings" role="tab" data-bs-toggle="tab">{{ lang.admin.rspamd_settings_map }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-password-settings" aria-selected="false" aria-controls="tab-config-password-settings" role="tab" data-bs-toggle="tab">{{ lang.admin.password_settings }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
|
||||
@@ -51,6 +52,7 @@
|
||||
{% include 'admin/tab-config-f2b.twig' %}
|
||||
{% include 'admin/tab-config-quarantine.twig' %}
|
||||
{% include 'admin/tab-config-quota.twig' %}
|
||||
{% include 'admin/tab-config-syncjob.twig' %}
|
||||
{% include 'admin/tab-config-rsettings.twig' %}
|
||||
{% include 'admin/tab-config-customize.twig' %}
|
||||
{% include 'admin/tab-config-password-settings.twig' %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="tab-pane fade" id="tab-config-syncjob" role="tabpanel" aria-labelledby="tab-config-syncjob">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-syncjob" data-bs-toggle="collapse" aria-controls="collapse-tab-config-syncjob">
|
||||
{{ lang.admin.syncjobs }}
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.admin.syncjobs }}</span>
|
||||
</div>
|
||||
<div id="collapse-tab-config-syncjob" class="card-body collapse" data-bs-parent="#admin-content">
|
||||
<form class="form" role="form" data-id="imapsync_settings" method="post">
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm-6">
|
||||
<label for="max_parallel">{{ lang.admin.syncjob_max_parallel }}:</label>
|
||||
<input type="number" class="form-control" id="max_parallel" name="max_parallel" min="1" max="1000" value="{{ imapsync_settings.max_parallel }}">
|
||||
<small class="text-muted">{{ lang.admin.syncjob_max_parallel_info }}</small>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="max_kb_per_second">{{ lang.admin.syncjob_max_kb_per_second }}:</label>
|
||||
<input type="number" class="form-control" id="max_kb_per_second" name="max_kb_per_second" min="0" max="1220000" value="{{ (imapsync_settings.max_bytes_per_second / 1024)|round }}">
|
||||
<small class="text-muted">{{ lang.admin.syncjob_max_kb_per_second_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-10">
|
||||
<a type="button" class="btn btn-sm d-block d-sm-inline btn-success" data-action="edit_selected"
|
||||
data-item="imapsync_settings"
|
||||
data-id="imapsync_settings"
|
||||
data-api-url='edit/imapsync_settings'
|
||||
data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.user.save_changes }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +158,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<hr>
|
||||
<form class="form-horizontal" data-id="editsyncjob_order" role="form" method="post">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="order_id">{{ lang.syncjobs.order_id }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="order_id" id="order_id" min="1" value="{{ result.order_id }}">
|
||||
<small class="text-muted">{{ lang.syncjobs.order_id_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-primary" data-action="edit_selected" data-id="editsyncjob_order" data-item="{{ result.id }}" data-api-url='edit/syncjob/order' data-api-attr='{}' href="#">{{ lang.syncjobs.set_position }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user