[Imapsync] Add parallel imapsync support

This commit is contained in:
FreddleSpl0it
2026-07-17 19:31:09 +02:00
parent 06a2a14c55
commit 496c3a51ac
13 changed files with 521 additions and 36 deletions
+1
View 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
View File
@@ -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
+102 -3
View File
@@ -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)) {
+26 -2
View File
@@ -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));
+7 -1
View File
@@ -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',
+7
View File
@@ -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
View File
@@ -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) {
+15 -4
View File
@@ -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",
+15 -4
View File
@@ -397,7 +397,12 @@
"user_quicklink": "Hide Quicklink to User Login Page",
"validate_license_now": "Validate GUID against license server",
"verify": "Verify",
"yes": "&#10003;"
"yes": "&#10003;",
"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",
+2
View File
@@ -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>
+17
View File
@@ -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 %}