1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-07-30 08:33:51 +00:00

[Imapsync] Add oauth support

This commit is contained in:
FreddleSpl0it
2026-07-17 16:04:42 +02:00
parent 175878f8f1
commit 06a2a14c55
31 changed files with 3082 additions and 783 deletions
+131
View File
@@ -414,3 +414,134 @@ function copyToClipboard(id) {
navigator.clipboard.writeText(copyText.value);
mailcow_alert_box(lang.copy_to_clipboard, "success");
}
// ===== IMAP sync sources: shared helpers + delegated listeners =====
// Used by the mailbox/user/edit pages (each still owns its own draw_imapsync_source_table).
// Kept here because 013-mailcow.js is loaded globally, so the logic lives in one place.
// Human-readable visibility label for a source row (used by the per-page source tables)
function imapsyncScopeDisplay(item) {
if (item.scope === 'all') return '<i class="bi bi-globe"></i> ' + lang.syncjobs.source_scope_all;
if (item.scope === 'domain') return escapeHtml(lang.syncjobs.source_scope_domain + ': ' + (item.domains || []).join(', '));
var us = item.users || [];
if (us.length === 0) return escapeHtml(item.created_by || '');
return escapeHtml(lang.syncjobs.source_scope_user + ': ' + us.join(', '));
}
// (Re)build every imapsync-source dropdown from the ACL-filtered API (fresh on modal open)
function populateImapsyncSourceSelects() {
$.get("/api/v1/get/syncjob_source/all", function(sources) {
$('select.imapsync-source-select').each(function() {
var $sel = $(this);
var keepId = $sel.data('current-source-id');
$sel.empty();
$sel.append('<option value="">' + (lang.syncjobs ? lang.syncjobs.source_select : '') + '</option>');
$.each(sources, function(_, src) {
var label = src.name + ' — ' + src.host1 + ':' + src.port1 + ' (' + src.auth_type + ')';
if (src.scope === 'all') label += ' [' + (lang.syncjobs ? lang.syncjobs.source_scope_all : 'all') + ']';
var $opt = $('<option/>').val(src.id).text(label)
.data('auth-type', src.auth_type).data('oauth-flow', src.oauth_flow);
if (keepId && parseInt(keepId) === parseInt(src.id)) $opt.attr('selected', 'selected');
$sel.append($opt);
});
if (typeof $sel.selectpicker === 'function') {
$sel.selectpicker('destroy');
$sel.selectpicker();
}
$sel.trigger('change');
});
});
}
// Populate a scope domain/user multiselect from the ACL-filtered endpoint (preserves existing options)
function fillImapsyncScopeSelect($sel, url, field) {
$.get('/api/v1/get/' + url, function(rows) {
var have = {};
$sel.find('option').each(function() { have[$(this).val()] = true; });
$.each(rows, function(_, r) {
var v = r[field];
if (!v || have[v]) return;
$sel.append($('<option/>').val(v).text(v));
});
// Rebuild the bootstrap-select wrapper (destroy + re-init)
if (typeof $sel.selectpicker === 'function') {
$sel.selectpicker('destroy');
$sel.selectpicker();
}
});
}
// Source editor: OAuth block visibility + authorization_code-only fields + redirect URI
function imapsyncSyncSourceOauthToggle($form) {
var isOauth = $form.find('select.imapsync-source-auth-type').val() === 'XOAUTH2';
var isAuthCode = $form.find('select.imapsync-source-oauth-flow').val() === 'authorization_code';
$form.find('.imapsync-source-oauth-block').toggle(isOauth);
$form.find('.imapsync-source-authcode-block').toggle(isOauth && isAuthCode);
$form.find('.imapsync-source-redirect-uri').val(window.location.origin + '/syncjob-oauth');
}
$(document).ready(function() {
// Syncjob form: toggle password row / OAuth "connect" button by the selected source's flow
$(document).on('change', 'select.imapsync-source-select', function() {
var $sel = $(this);
var $opt = $sel.find('option:selected');
var $form = $sel.closest('form');
var isOauth = ($opt.data('auth-type') === 'XOAUTH2');
var isAuthCode = isOauth && ($opt.data('oauth-flow') === 'authorization_code');
$form.find('.password1-row').toggle(!isOauth);
$form.find('input[name="password1"]').prop('required', !isOauth);
$form.find('.oauth-source-hint').toggle(isOauth);
$form.find('.imapsync-oauth-connect-row').toggle(isAuthCode);
});
// Source editor: OAuth block + authorization_code fields
$(document).on('change', 'select.imapsync-source-auth-type, select.imapsync-source-oauth-flow', function() {
imapsyncSyncSourceOauthToggle($(this).closest('form'));
});
// Source editor: scope -> show + populate the domain/user pickers
$(document).on('change', 'select.imapsync-source-scope', function() {
var $form = $(this).closest('form');
var scope = $(this).val();
$form.find('.imapsync-source-domains-row').toggle(scope === 'domain');
$form.find('.imapsync-source-users-row').toggle(scope === 'user');
if (scope === 'domain') fillImapsyncScopeSelect($form.find('.imapsync-source-domains'), 'domain/all', 'domain_name');
if (scope === 'user') fillImapsyncScopeSelect($form.find('.imapsync-source-users'), 'mailbox/all', 'username');
});
// "Connect with the provider": open the per-user OAuth popup for the selected source
$(document).on('click', '.imapsync-oauth-connect-btn', function() {
var $form = $(this).closest('form');
var sourceId = $form.find('select.imapsync-source-select').val();
if (!sourceId) return;
window.open('/syncjob-oauth?action=start&source_id=' + encodeURIComponent(sourceId),
'syncjob_oauth', 'width=600,height=700');
});
// Result from the OAuth popup: fill + lock user1, mark connected (or show the error)
window.addEventListener('message', function(ev) {
if (ev.origin !== window.location.origin || !ev.data || ev.data.type !== 'syncjob_oauth') return;
$('.imapsync-oauth-connect-row:visible').each(function() {
var $form = $(this).closest('form');
var $status = $form.find('.imapsync-oauth-connect-status');
if (ev.data.ok) {
$form.find('input[name="user1"]').val(ev.data.user1).prop('readonly', true);
$status.removeClass('text-danger').addClass('text-success')
.text((lang.syncjobs ? lang.syncjobs.syncjob_oauth_connected : 'Connected') + ': ' + ev.data.user1);
} else {
$status.removeClass('text-success').addClass('text-danger').text(ev.data.error || 'error');
}
});
});
// Add-source modal opens: init scope pickers + OAuth block (auth_type may be cached-form preselected)
$(document).on('shown.bs.modal', '#addImapsyncSourceModal', function() {
$(this).find('select.imapsync-source-scope').trigger('change');
imapsyncSyncSourceOauthToggle($(this).find('form'));
});
// Add-syncjob modal opens: (re)load the source dropdown from the API
$(document).on('shown.bs.modal', '#addSyncJobModalAdmin, #addSyncJobModal', function() {
populateImapsyncSourceSelects();
});
});
+11
View File
@@ -240,4 +240,15 @@ jQuery(function($){
// Draw Table if tab is active
onVisible("[id^=wl_policy_domain_table]", () => draw_wl_policy_domain_table());
onVisible("[id^=bl_policy_domain_table]", () => draw_bl_policy_domain_table());
// initialise imapsync-source form on load.
if ($('select.imapsync-source-oauth-flow').length) {
imapsyncSyncSourceOauthToggle($('select.imapsync-source-oauth-flow').closest('form'));
}
if ($('select.imapsync-source-scope').length) {
$('select.imapsync-source-scope').trigger('change');
}
if ($('select.imapsync-source-select').length) {
populateImapsyncSourceSelects();
}
});
+65 -5
View File
@@ -2173,7 +2173,7 @@ jQuery(function($){
} else {
item.exclude = '<code>' + escapeHtml(item.exclude) + '</code>';
}
item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.host1) + ':' + escapeHtml(item.port1);
item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.source_name || '?') + ' (' + escapeHtml(item.source_host || '?') + ':' + escapeHtml(item.source_port || '?') + ')';
item.action = '<div class="btn-group">' +
'<a href="/edit/syncjob/' + item.id + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a href="#" data-action="delete_selected" data-id="single-syncjob" data-api-url="delete/syncjob" data-item="' + item.id + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
@@ -2193,10 +2193,10 @@ jQuery(function($){
} else {
item.success = '<i class="text-' + (item.success == 1 ? 'success' : 'danger') + ' bi bi-' + (item.success == 1 ? 'check-lg' : 'x-lg') + '"></i>';
}
if (lang['syncjob_'+item.exit_status]) {
item.exit_status = lang['syncjob_'+item.exit_status];
if (lang.syncjobs['syncjob_'+item.exit_status]) {
item.exit_status = lang.syncjobs['syncjob_'+item.exit_status];
} else if (item.success != '-') {
item.exit_status = lang.syncjob_check_log;
item.exit_status = lang.syncjobs.syncjob_check_log;
}
item.exit_status = item.success + ' ' + item.exit_status;
});
@@ -2245,7 +2245,7 @@ jQuery(function($){
defaultContent: ''
},
{
title: lang.syncjob_last_run_result,
title: lang.syncjobs.syncjob_last_run_result,
data: 'exit_status',
defaultContent: ''
},
@@ -2465,6 +2465,65 @@ jQuery(function($){
});
}
function draw_imapsync_source_table() {
if ($.fn.DataTable.isDataTable('#imapsync_source_table')) {
$('#imapsync_source_table').DataTable().columns.adjust().responsive.recalc();
return;
}
$('#imapsync_source_table').DataTable({
responsive: true,
processing: true,
serverSide: false,
stateSave: true,
pageLength: pagination_size,
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
ajax: {
type: "GET",
url: "/api/v1/get/syncjob_source/all",
dataSrc: function(json) {
$.each(json, function(i, item) {
item.endpoint = escapeHtml(item.host1 + ':' + item.port1 + ' (' + item.enc1 + ')');
item.auth_display = escapeHtml(item.auth_type);
if (item.auth_type === 'XOAUTH2') {
if (item.oauth_token_expires && item.oauth_token_expires * 1000 > Date.now()) {
item.auth_display += ' <span class="badge bg-success">' + lang.syncjobs.source_token_status + ': ' + new Date(item.oauth_token_expires * 1000).toLocaleString() + '</span>';
} else if (item.oauth_last_refresh_error) {
item.auth_display += ' <span class="badge bg-danger" title="' + escapeHtml(item.oauth_last_refresh_error) + '">' + lang.syncjobs.source_token_error + '</span>';
} else {
item.auth_display += ' <span class="badge bg-warning">' + lang.waiting + '</span>';
}
}
item.visibility_display = imapsyncScopeDisplay(item);
if (item.can_edit) {
item.action = '<div class="btn-group">' +
'<a href="/edit/syncjob_source/' + item.id + '" class="btn btn-xs btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a href="#" data-action="delete_selected" data-id="single-syncjob_source" data-api-url="delete/syncjob_source" data-item="' + item.id + '" class="btn btn-xs btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
} else {
item.action = '<span class="text-muted">-</span>';
}
});
return json;
}
},
columns: [
{ title: 'ID', data: 'id', defaultContent: '' },
{ title: lang.syncjobs.source_name, data: 'name', defaultContent: '', render: (d) => escapeHtml(d || '') },
{ title: lang.syncjobs.source_scope, data: 'visibility_display', defaultContent: '' },
{ title: 'Endpoint', data: 'endpoint', defaultContent: '' },
{ title: lang.syncjobs.source_auth_type, data: 'auth_display', defaultContent: '' },
{ title: lang.active, data: 'active', defaultContent: '',
render: function(d) { return d == 1 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }},
{ title: lang.action, data: 'action', defaultContent: '', className: 'dt-text-right' }
]
});
}
// Shared imapsync helpers + delegated listeners live in js/build/013-mailcow.js
// (draw_imapsync_source_table stays per-page and calls the global imapsyncScopeDisplay).
// Load only if the tab is visible
onVisible("[id^=domain_table]", () => draw_domain_table());
onVisible("[id^=templates_domain_table]", () => draw_templates_domain_table());
@@ -2474,6 +2533,7 @@ jQuery(function($){
onVisible("[id^=alias_table]", () => draw_alias_table());
onVisible("[id^=aliasdomain_table]", () => draw_aliasdomain_table());
onVisible("[id^=sync_job_table]", () => draw_sync_job_table());
onVisible("[id^=imapsync_source_table]", () => draw_imapsync_source_table());
onVisible("[id^=filter_table]", () => draw_filter_table());
onVisible("[id^=bcc_table]", () => draw_bcc_table());
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
+65 -5
View File
@@ -284,7 +284,7 @@ jQuery(function($){
} else {
item.exclude = '<code>' + escapeHtml(item.exclude) + '</code>';
}
item.server_w_port = escapeHtml(item.user1 + '@' + item.host1 + ':' + item.port1);
item.server_w_port = escapeHtml(item.user1 + '@' + (item.source_name || '?') + ' (' + (item.source_host || '?') + ':' + (item.source_port || '?') + ')');
if (acl_data.syncjobs === 1) {
item.action = '<div class="btn-group">' +
'<a href="/edit/syncjob/' + item.id + '" class="btn btn-xs btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
@@ -310,10 +310,10 @@ jQuery(function($){
} else {
item.success = '<i class="text-' + (item.success == 1 ? 'success' : 'danger') + ' bi bi-' + (item.success == 1 ? 'check-lg' : 'x-lg') + '"></i>';
}
if (lang['syncjob_'+item.exit_status]) {
item.exit_status = lang['syncjob_'+item.exit_status];
if (lang.syncjobs['syncjob_'+item.exit_status]) {
item.exit_status = lang.syncjobs['syncjob_'+item.exit_status];
} else if (item.success != '-') {
item.exit_status = lang.syncjob_check_log;
item.exit_status = lang.syncjobs.syncjob_check_log;
}
item.exit_status = item.success + ' ' + item.exit_status;
});
@@ -362,7 +362,7 @@ jQuery(function($){
defaultContent: ''
},
{
title: lang.syncjob_last_run_result,
title: lang.syncjobs.syncjob_last_run_result,
data: 'exit_status',
defaultContent: ''
},
@@ -694,11 +694,71 @@ jQuery(function($){
});
}
function draw_imapsync_source_table() {
if ($.fn.DataTable.isDataTable('#imapsync_source_table')) {
$('#imapsync_source_table').DataTable().columns.adjust().responsive.recalc();
return;
}
$('#imapsync_source_table').DataTable({
responsive: true,
processing: true,
serverSide: false,
stateSave: true,
pageLength: pagination_size,
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
ajax: {
type: "GET",
url: "/api/v1/get/syncjob_source/all",
dataSrc: function(json) {
$.each(json, function(i, item) {
item.endpoint = escapeHtml(item.host1 + ':' + item.port1 + ' (' + item.enc1 + ')');
item.auth_display = escapeHtml(item.auth_type);
if (item.auth_type === 'XOAUTH2') {
if (item.oauth_token_expires && item.oauth_token_expires * 1000 > Date.now()) {
item.auth_display += ' <span class="badge bg-success">' + lang.syncjobs.source_token_status + ': ' + new Date(item.oauth_token_expires * 1000).toLocaleString() + '</span>';
} else if (item.oauth_last_refresh_error) {
item.auth_display += ' <span class="badge bg-danger" title="' + escapeHtml(item.oauth_last_refresh_error) + '">' + lang.syncjobs.source_token_error + '</span>';
} else {
item.auth_display += ' <span class="badge bg-warning">' + lang.waiting + '</span>';
}
}
item.visibility_display = imapsyncScopeDisplay(item);
if (acl_data.syncjobs === 1 && item.can_edit) {
item.action = '<div class="btn-group">' +
'<a href="/edit/syncjob_source/' + item.id + '" class="btn btn-xs btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a href="#" data-action="delete_selected" data-id="single-syncjob_source" data-api-url="delete/syncjob_source" data-item="' + item.id + '" class="btn btn-xs btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
} else {
item.action = '<span class="text-muted">-</span>';
}
});
return json;
}
},
columns: [
{ title: 'ID', data: 'id', defaultContent: '' },
{ title: lang.syncjobs.source_name, data: 'name', defaultContent: '', render: (d) => escapeHtml(d || '') },
{ title: lang.syncjobs.source_scope, data: 'visibility_display', defaultContent: '' },
{ title: 'Endpoint', data: 'endpoint', defaultContent: '' },
{ title: lang.syncjobs.source_auth_type, data: 'auth_display', defaultContent: '' },
{ title: lang.active, data: 'active', defaultContent: '',
render: function(d) { return d == 1 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }},
{ title: lang.action, data: 'action', defaultContent: '', className: 'dt-text-right' }
]
});
}
// Shared imapsync helpers + delegated listeners live in js/build/013-mailcow.js
// (draw_imapsync_source_table stays per-page and calls the global imapsyncScopeDisplay).
// Load only if the tab is visible
onVisible("[id^=tla_table]", () => draw_tla_table());
onVisible("[id^=bl_policy_mailbox_table]", () => draw_bl_policy_mailbox_table());
onVisible("[id^=wl_policy_mailbox_table]", () => draw_wl_policy_mailbox_table());
onVisible("[id^=sync_job_table]", () => draw_sync_job_table());
onVisible("[id^=imapsync_source_table]", () => draw_imapsync_source_table());
onVisible("[id^=app_passwd_table]", () => draw_app_passwd_table());
onVisible("[id^=recent-logins]", () => last_logins('get'));
});