mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-26 10:50:43 +00:00
email UI - overhaul - progress information for UI
This commit is contained in:
parent
12bf5e418e
commit
2e4b2ca7e7
7 changed files with 589 additions and 198 deletions
|
|
@ -742,8 +742,12 @@
|
|||
"form.cloudflare.submit": "Update Cloudflare Credentials",
|
||||
"nav.email": "Email",
|
||||
"email.title": "Email Management",
|
||||
"email.title_description": "Manage Cloudflare email routing, mailboxes and workers.",
|
||||
"email.domain_setup": "Domain Setup",
|
||||
"email.domain_setup_description": "Enable email routing and deploy workers for a Cloudflare zone.",
|
||||
"email.mailbox_management": "Mailboxes",
|
||||
"email.mailbox_description": "Create and manage inboxes across your configured domains.",
|
||||
"email.no_mailboxes": "No mailboxes configured yet.",
|
||||
"email.permissions_title": "Permissions Required",
|
||||
"email.permission_email_routing": "Email Routing",
|
||||
"email.permission_workers": "Workers Scripts",
|
||||
|
|
@ -764,6 +768,8 @@
|
|||
"email.webmail_link": "Open Webmail",
|
||||
"email.container_status": "Container Status",
|
||||
"email.statistics": "Statistics",
|
||||
"email.statistics_description": "Overview of email activity across all configured domains.",
|
||||
"email.backup_restore_description": "Export or import your email database and attachments.",
|
||||
"email.dns_records": "DNS Records",
|
||||
"email.delete": "Delete",
|
||||
"email.domain": "Domain",
|
||||
|
|
@ -793,15 +799,41 @@
|
|||
"email.teardown_type_to_confirm": "Type {domain} to confirm:",
|
||||
"email.teardown_success": "Teardown complete.",
|
||||
"email.teardown_errors": "Completed with errors: ",
|
||||
"email.nuke_all_title": "Nuke All Domains",
|
||||
"email.nuke_all_title": "Remove All Domains",
|
||||
"email.nuke_all_description": "Remove all configured email domains. Partial removes Cloudflare resources only. Complete also destroys all local email data.",
|
||||
"email.nuke_all_partial": "Nuke All (Partial)",
|
||||
"email.nuke_all_complete": "Nuke All (Complete)",
|
||||
"email.nuke_all_partial": "Remove All (Partial)",
|
||||
"email.nuke_all_complete": "Remove All (Complete)",
|
||||
"email.nuke_all_confirm_1": "Remove ALL Cloudflare email resources across ALL domains? Workers, DNS, R2 buckets and routing rules will be deleted.",
|
||||
"email.nuke_all_confirm_2": "This will also permanently destroy ALL local email data across ALL domains. This cannot be undone.",
|
||||
"email.nuke_all_type_to_confirm": "Type NUKE ALL to confirm:",
|
||||
"email.orphaned_data_title": "Orphaned Local Data",
|
||||
"email.orphaned_data_description": "These domains were torn down but local email data remains. Download a backup before wiping.",
|
||||
"email.wipe_local_data": "Wipe Local Data",
|
||||
"email.wipe_local_confirm": "Permanently delete all local email data for {domain}? Type the domain name to confirm."
|
||||
"email.wipe_local_confirm": "Permanently delete all local email data for {domain}? Type the domain name to confirm.",
|
||||
"email.progress.setup_domain": "Setting up {zone}",
|
||||
"email.progress.create_mailbox": "Creating mailbox",
|
||||
"email.progress.delete_mailbox": "Deleting mailbox",
|
||||
"email.progress.repair_dns": "Repairing DNS for {zone}",
|
||||
"email.progress.teardown_domain": "Tearing down {zone}",
|
||||
"email.progress.teardown_all": "Removing all domains",
|
||||
"email.progress.update_r2": "Updating R2 credentials",
|
||||
"email.progress.redeploy_workers": "Redeploying workers",
|
||||
"email.progress.step_enable_routing": "Enable email routing",
|
||||
"email.progress.step_dns_records": "Configure DNS records",
|
||||
"email.progress.step_r2_bucket": "Create R2 storage bucket",
|
||||
"email.progress.step_r2_credentials": "Fetch R2 credentials",
|
||||
"email.progress.step_generate_keys": "Generate security keys",
|
||||
"email.progress.step_deploy_inbound": "Deploy inbound worker",
|
||||
"email.progress.step_deploy_outbound": "Deploy outbound worker",
|
||||
"email.progress.step_routing_rule": "Configure routing rule",
|
||||
"email.progress.step_restart_container": "Restart mail service",
|
||||
"email.progress.step_create_rule": "Create routing rule",
|
||||
"email.progress.step_remove_rule": "Remove routing rule",
|
||||
"email.progress.step_remove_rules": "Remove Cloudflare resources",
|
||||
"email.progress.step_redeploy_inbound": "Redeploy inbound worker",
|
||||
"email.progress.step_wipe_local": "Wipe local email data",
|
||||
"email.progress.step_delete_workers": "Delete Cloudflare workers",
|
||||
"email.progress.step_remove_dns": "Remove DNS records",
|
||||
"email.progress.step_save_credentials": "Save credentials",
|
||||
"email.progress.step_repair_dns": "Repair DNS records"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,7 +333,8 @@ def main_application_entrypoint():
|
|||
'host': config.WAITRESS_HOST,
|
||||
'port': config.WAITRESS_PORT,
|
||||
'threads': config.WAITRESS_THREADS,
|
||||
'expose_tracebacks': False
|
||||
'expose_tracebacks': False,
|
||||
'send_bytes': 1,
|
||||
}
|
||||
|
||||
if config.WAITRESS_CONNECTION_LIMIT:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -456,6 +456,170 @@ function findRowByRuleKey(ruleId) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const emailProgressState = {
|
||||
steps: [],
|
||||
timers: [],
|
||||
autoCloseTimer: null,
|
||||
activeIndex: -1,
|
||||
};
|
||||
|
||||
const EMAIL_OPERATION_STEPS = {
|
||||
setup_domain: [
|
||||
{ key: 'email.progress.step_enable_routing', ms: 3000 },
|
||||
{ key: 'email.progress.step_dns_records', ms: 4000 },
|
||||
{ key: 'email.progress.step_r2_bucket', ms: 3000 },
|
||||
{ key: 'email.progress.step_r2_credentials', ms: 2000 },
|
||||
{ key: 'email.progress.step_generate_keys', ms: 2000 },
|
||||
{ key: 'email.progress.step_deploy_inbound', ms: 6000 },
|
||||
{ key: 'email.progress.step_deploy_outbound',ms: 6000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 3000 },
|
||||
],
|
||||
create_mailbox: [
|
||||
{ key: 'email.progress.step_create_rule', ms: 3000 },
|
||||
{ key: 'email.progress.step_redeploy_inbound', ms: 6000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 3000 },
|
||||
],
|
||||
delete_mailbox: [
|
||||
{ key: 'email.progress.step_remove_rule', ms: 3000 },
|
||||
{ key: 'email.progress.step_redeploy_inbound', ms: 6000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 3000 },
|
||||
],
|
||||
repair_dns: [
|
||||
{ key: 'email.progress.step_repair_dns', ms: 5000 },
|
||||
],
|
||||
teardown_domain: [
|
||||
{ key: 'email.progress.step_remove_rule', ms: 3000 },
|
||||
{ key: 'email.progress.step_delete_workers', ms: 4000 },
|
||||
{ key: 'email.progress.step_remove_dns', ms: 3000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 2000 },
|
||||
],
|
||||
teardown_all: [
|
||||
{ key: 'email.progress.step_remove_rule', ms: 5000 },
|
||||
{ key: 'email.progress.step_delete_workers', ms: 5000 },
|
||||
{ key: 'email.progress.step_remove_dns', ms: 4000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 2000 },
|
||||
],
|
||||
update_r2: [
|
||||
{ key: 'email.progress.step_save_credentials', ms: 2000 },
|
||||
{ key: 'email.progress.step_restart_container', ms: 3000 },
|
||||
],
|
||||
redeploy_workers: [
|
||||
{ key: 'email.progress.step_deploy_inbound', ms: 6000 },
|
||||
{ key: 'email.progress.step_deploy_outbound',ms: 6000 },
|
||||
],
|
||||
};
|
||||
|
||||
function emailProgressShowPanel(title) {
|
||||
const panel = document.getElementById('emailProgressPanel');
|
||||
if (!panel) return;
|
||||
const titleEl = document.getElementById('emailProgressTitle');
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
document.getElementById('emailProgressSteps').innerHTML = '';
|
||||
panel.classList.remove('translate-y-4', 'opacity-0', 'pointer-events-none');
|
||||
panel.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto');
|
||||
}
|
||||
|
||||
function emailProgressHidePanel() {
|
||||
const panel = document.getElementById('emailProgressPanel');
|
||||
if (!panel) return;
|
||||
emailProgressState.timers.forEach(t => clearTimeout(t));
|
||||
emailProgressState.timers = [];
|
||||
if (emailProgressState.autoCloseTimer) {
|
||||
clearTimeout(emailProgressState.autoCloseTimer);
|
||||
emailProgressState.autoCloseTimer = null;
|
||||
}
|
||||
emailProgressState.steps = [];
|
||||
emailProgressState.activeIndex = -1;
|
||||
panel.classList.add('translate-y-4', 'opacity-0', 'pointer-events-none');
|
||||
panel.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto');
|
||||
}
|
||||
|
||||
function emailProgressRenderSteps() {
|
||||
const list = document.getElementById('emailProgressSteps');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
for (const step of emailProgressState.steps) {
|
||||
const li = document.createElement('li');
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'w-4 text-center shrink-0 inline-flex items-center justify-center';
|
||||
if (step.status === 'done') {
|
||||
li.className = 'flex items-center gap-2 text-success';
|
||||
iconSpan.textContent = '✓';
|
||||
} else if (step.status === 'error') {
|
||||
li.className = 'flex items-center gap-2 text-error';
|
||||
iconSpan.textContent = '✗';
|
||||
} else if (step.status === 'active') {
|
||||
li.className = 'flex items-center gap-2 text-primary';
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'loading loading-spinner loading-xs';
|
||||
iconSpan.appendChild(spinner);
|
||||
} else {
|
||||
li.className = 'flex items-center gap-2 opacity-40';
|
||||
iconSpan.textContent = '·';
|
||||
}
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = t(step.key) || step.key;
|
||||
li.appendChild(iconSpan);
|
||||
li.appendChild(labelSpan);
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function _emailProgressActivateStep(index, stepDefs) {
|
||||
if (index >= emailProgressState.steps.length) return;
|
||||
emailProgressState.activeIndex = index;
|
||||
emailProgressState.steps[index].status = 'active';
|
||||
emailProgressRenderSteps();
|
||||
const timer = setTimeout(() => {
|
||||
if (emailProgressState.steps[index]) {
|
||||
emailProgressState.steps[index].status = 'done';
|
||||
emailProgressRenderSteps();
|
||||
}
|
||||
_emailProgressActivateStep(index + 1, stepDefs);
|
||||
}, stepDefs[index].ms);
|
||||
emailProgressState.timers.push(timer);
|
||||
}
|
||||
|
||||
function emailProgressStart(stepDefs, title) {
|
||||
emailProgressHidePanel();
|
||||
emailProgressState.steps = stepDefs.map(s => ({ key: s.key, status: 'pending' }));
|
||||
emailProgressShowPanel(title);
|
||||
emailProgressRenderSteps();
|
||||
_emailProgressActivateStep(0, stepDefs);
|
||||
}
|
||||
|
||||
function emailProgressFinish(success) {
|
||||
emailProgressState.timers.forEach(t => clearTimeout(t));
|
||||
emailProgressState.timers = [];
|
||||
if (success) {
|
||||
let delay = 0;
|
||||
for (let i = 0; i < emailProgressState.steps.length; i++) {
|
||||
if (emailProgressState.steps[i].status !== 'done') {
|
||||
const idx = i;
|
||||
const timer = setTimeout(() => {
|
||||
if (emailProgressState.steps[idx]) {
|
||||
emailProgressState.steps[idx].status = 'done';
|
||||
emailProgressRenderSteps();
|
||||
}
|
||||
}, delay);
|
||||
emailProgressState.timers.push(timer);
|
||||
delay += 150;
|
||||
}
|
||||
}
|
||||
emailProgressState.autoCloseTimer = setTimeout(() => {
|
||||
emailProgressHidePanel();
|
||||
location.reload();
|
||||
}, delay + 1500);
|
||||
} else {
|
||||
const active = emailProgressState.steps.findIndex(s => s.status === 'active');
|
||||
if (active >= 0) emailProgressState.steps[active].status = 'error';
|
||||
for (let i = active + 1; i < emailProgressState.steps.length; i++) {
|
||||
emailProgressState.steps[i].status = 'pending';
|
||||
}
|
||||
emailProgressRenderSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStructuredStateEvent(message) {
|
||||
const eventType = message.type;
|
||||
const data = message.data || {};
|
||||
|
|
@ -493,7 +657,7 @@ function connectStateUpdateSource() {
|
|||
return;
|
||||
}
|
||||
|
||||
const streamUrl = `${document.baseURI}stream-state-updates`;
|
||||
const streamUrl = `${window.location.origin}/stream-state-updates`;
|
||||
if (activeStateEventSource) {
|
||||
activeStateEventSource.close();
|
||||
}
|
||||
|
|
@ -1524,6 +1688,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
connectStateUpdateSource();
|
||||
scheduleServicesSnapshotRefresh();
|
||||
|
||||
document.getElementById('emailProgressClose')?.addEventListener('click', emailProgressHidePanel);
|
||||
|
||||
startServerPing();
|
||||
|
||||
if (document.getElementById('idp-table-container')) {
|
||||
|
|
@ -1932,11 +2098,16 @@ async function emailCheckPermissions() {
|
|||
}
|
||||
}
|
||||
|
||||
async function emailSetupDomain() {
|
||||
async function emailSetupDomain(event) {
|
||||
const select = document.getElementById('emailZoneSelect');
|
||||
if (!select || !select.value) return;
|
||||
const zoneId = select.value;
|
||||
const zoneName = select.options[select.selectedIndex].text;
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
const title = (t('email.progress.setup_domain') || 'Setting up {zone}').replace('{zone}', zoneName);
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.setup_domain, title);
|
||||
try {
|
||||
const response = await fetch('/email/setup-domain', {
|
||||
method: 'POST',
|
||||
|
|
@ -1945,11 +2116,15 @@ async function emailSetupDomain() {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
await dfAlert(data.error || 'Error', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -2012,18 +2187,23 @@ async function dfConfirmTyped(message, expectedValue, title) {
|
|||
});
|
||||
}
|
||||
|
||||
async function emailTeardownPartial(domain) {
|
||||
async function emailTeardownPartial(domain, event) {
|
||||
if (!await dfConfirm(t('email.teardown_confirm_remote').replace('{domain}', domain), t('email.teardown_partial'))) return;
|
||||
await _emailTeardown(domain, false);
|
||||
await _emailTeardown(domain, false, event);
|
||||
}
|
||||
|
||||
async function emailTeardownComplete(domain) {
|
||||
async function emailTeardownComplete(domain, event) {
|
||||
if (!await dfConfirm(t('email.teardown_confirm_local').replace('{domain}', domain), t('email.teardown_complete'))) return;
|
||||
if (!await dfConfirmTyped(t('email.teardown_type_to_confirm').replace('{domain}', domain), domain, t('email.teardown_complete'))) return;
|
||||
await _emailTeardown(domain, true);
|
||||
await _emailTeardown(domain, true, event);
|
||||
}
|
||||
|
||||
async function _emailTeardown(domain, includeLocalData) {
|
||||
async function _emailTeardown(domain, includeLocalData, event) {
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
const title = (t('email.progress.teardown_domain') || 'Tearing down {zone}').replace('{zone}', domain);
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.teardown_domain, title);
|
||||
try {
|
||||
const response = await fetch('/email/teardown-domain', {
|
||||
method: 'POST',
|
||||
|
|
@ -2032,17 +2212,22 @@ async function _emailTeardown(domain, includeLocalData) {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
emailProgressFinish(true);
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
await dfAlert(t('email.teardown_errors') + '\n' + data.errors.join('\n'), t('email.teardown_complete'));
|
||||
}
|
||||
location.reload();
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailNukeAll(includeLocalData) {
|
||||
async function emailNukeAll(includeLocalData, event) {
|
||||
if (includeLocalData) {
|
||||
if (!await dfConfirm(t('email.nuke_all_confirm_1'), t('email.nuke_all_complete'))) return;
|
||||
if (!await dfConfirm(t('email.nuke_all_confirm_2'), t('email.nuke_all_complete'))) return;
|
||||
|
|
@ -2050,9 +2235,10 @@ async function emailNukeAll(includeLocalData) {
|
|||
} else {
|
||||
if (!await dfConfirm(t('email.nuke_all_confirm_1'), t('email.nuke_all_partial'))) return;
|
||||
}
|
||||
const feedback = document.getElementById('emailNukeFeedback');
|
||||
feedback.classList.remove('hidden', 'text-error', 'text-success');
|
||||
feedback.textContent = t('common.loading');
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.teardown_all, t('email.progress.teardown_all') || 'Removing all domains');
|
||||
try {
|
||||
const response = await fetch('/email/teardown-all', {
|
||||
method: 'POST',
|
||||
|
|
@ -2061,15 +2247,22 @@ async function emailNukeAll(includeLocalData) {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
emailProgressFinish(true);
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const feedback = document.getElementById('emailNukeFeedback');
|
||||
feedback.classList.remove('hidden', 'text-error', 'text-success');
|
||||
feedback.classList.add('text-error');
|
||||
feedback.textContent = t('email.teardown_errors') + data.errors.join(', ');
|
||||
setTimeout(() => location.reload(), 3000);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
const feedback = document.getElementById('emailNukeFeedback');
|
||||
feedback.classList.remove('hidden', 'text-error', 'text-success');
|
||||
feedback.classList.add('text-error');
|
||||
feedback.textContent = e.message;
|
||||
console.error(e);
|
||||
|
|
@ -2116,11 +2309,15 @@ async function emailWipeLocal(domain) {
|
|||
}
|
||||
}
|
||||
|
||||
async function emailCreateMailbox() {
|
||||
async function emailCreateMailbox(event) {
|
||||
const address = document.getElementById('newMailboxAddress').value;
|
||||
const domain = document.getElementById('newMailboxDomain').value;
|
||||
const name = document.getElementById('newMailboxName').value;
|
||||
if (!address || !domain) return;
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.create_mailbox, t('email.progress.create_mailbox') || 'Creating mailbox');
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/create', {
|
||||
method: 'POST',
|
||||
|
|
@ -2128,14 +2325,26 @@ async function emailCreateMailbox() {
|
|||
body: JSON.stringify({ address: address + '@' + domain, domain: domain, display_name: name })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) location.reload();
|
||||
if (data.success) {
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
if (data.error) await dfAlert(data.error, 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailDeleteMailbox(address, domain) {
|
||||
async function emailDeleteMailbox(address, domain, event) {
|
||||
if (!await dfConfirm('Delete mailbox?', 'Delete')) return;
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.delete_mailbox, t('email.progress.delete_mailbox') || 'Deleting mailbox');
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/delete', {
|
||||
method: 'POST',
|
||||
|
|
@ -2143,13 +2352,23 @@ async function emailDeleteMailbox(address, domain) {
|
|||
body: JSON.stringify({ address: address, domain: domain })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) location.reload();
|
||||
if (data.success) {
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function emailVerifyDns(domain) {
|
||||
async function emailVerifyDns(domain, event) {
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
try {
|
||||
const response = await fetch('/email/verify-dns', {
|
||||
method: 'POST',
|
||||
|
|
@ -2162,14 +2381,20 @@ async function emailVerifyDns(domain) {
|
|||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
}
|
||||
}
|
||||
|
||||
async function emailUpdateR2(domain) {
|
||||
async function emailUpdateR2(domain, event) {
|
||||
const accessKeyId = prompt('R2 Access Key ID (from CF Dashboard → R2 → Manage R2 API Tokens):');
|
||||
if (!accessKeyId) return;
|
||||
const secretAccessKey = prompt('R2 Secret Access Key:');
|
||||
if (!secretAccessKey) return;
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.update_r2, t('email.progress.update_r2') || 'Updating R2 credentials');
|
||||
try {
|
||||
const response = await fetch('/email/update-r2-credentials', {
|
||||
method: 'POST',
|
||||
|
|
@ -2178,12 +2403,16 @@ async function emailUpdateR2(domain) {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert('R2 credentials updated and mail-manager restarted.', 'Success');
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
emailProgressFinish(false);
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2191,8 +2420,12 @@ function emailOpenWebmail() {
|
|||
window.location.href = '/email/sso/callback';
|
||||
}
|
||||
|
||||
async function emailRepairDns(domain) {
|
||||
async function emailRepairDns(domain, event) {
|
||||
if (!confirm(`Re-apply all required DNS records for ${domain}? Missing records (including DKIM) will be added.`)) return;
|
||||
const btn = event?.currentTarget;
|
||||
const originalHTML = btn?.innerHTML;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.repair_dns, t('email.progress.repair_dns') || 'Repairing DNS');
|
||||
try {
|
||||
const response = await fetch('/email/repair-dns', {
|
||||
method: 'POST',
|
||||
|
|
@ -2201,46 +2434,76 @@ async function emailRepairDns(domain) {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert(`DNS records repaired for ${domain}.`, 'Success');
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
emailProgressFinish(false);
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
|
||||
}
|
||||
}
|
||||
|
||||
async function emailSetPassword(address, domain) {
|
||||
const password = prompt(`New password for ${address} (min 8 characters):`);
|
||||
if (!password) return;
|
||||
if (password.length < 8) {
|
||||
await dfAlert('Password must be at least 8 characters.', 'Error');
|
||||
return;
|
||||
}
|
||||
const confirmed = prompt('Confirm password:');
|
||||
if (password !== confirmed) {
|
||||
await dfAlert('Passwords do not match.', 'Error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/set-password', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ address, domain, password })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert(`Password set for ${address}.`, 'Success');
|
||||
} else {
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
const modal = document.getElementById('emailSetPasswordModal');
|
||||
if (!modal) return;
|
||||
const targetEl = document.getElementById('emailSetPasswordTarget');
|
||||
const newPwEl = document.getElementById('emailSetPasswordNew');
|
||||
const confirmEl = document.getElementById('emailSetPasswordConfirm');
|
||||
const errorEl = document.getElementById('emailSetPasswordError');
|
||||
const submitBtn = document.getElementById('emailSetPasswordSubmitBtn');
|
||||
|
||||
if (targetEl) targetEl.textContent = address;
|
||||
if (newPwEl) newPwEl.value = '';
|
||||
if (confirmEl) confirmEl.value = '';
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
modal.showModal();
|
||||
setTimeout(() => newPwEl?.focus(), 100);
|
||||
|
||||
const handler = async () => {
|
||||
submitBtn.removeEventListener('click', handler);
|
||||
const password = newPwEl.value;
|
||||
const confirmed = confirmEl.value;
|
||||
errorEl.classList.add('hidden');
|
||||
if (password.length < 8) {
|
||||
errorEl.textContent = 'Password must be at least 8 characters.';
|
||||
errorEl.classList.remove('hidden');
|
||||
submitBtn.addEventListener('click', handler);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
if (password !== confirmed) {
|
||||
errorEl.textContent = 'Passwords do not match.';
|
||||
errorEl.classList.remove('hidden');
|
||||
submitBtn.addEventListener('click', handler);
|
||||
return;
|
||||
}
|
||||
modal.close();
|
||||
try {
|
||||
const response = await fetch('/email/mailbox/set-password', {
|
||||
method: 'POST',
|
||||
headers: buildApiHeaders({'Content-Type': 'application/json'}),
|
||||
body: JSON.stringify({ address, domain, password })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert(`Password updated for ${address}.`, 'Success');
|
||||
} else {
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
submitBtn.addEventListener('click', handler);
|
||||
}
|
||||
|
||||
async function emailRedeployWorkers() {
|
||||
if (!confirm('Redeploy all inbound and outbound workers? This will push the latest worker code and bindings to Cloudflare.')) return;
|
||||
emailProgressStart(EMAIL_OPERATION_STEPS.redeploy_workers, t('email.progress.redeploy_workers') || 'Redeploying workers');
|
||||
try {
|
||||
const response = await fetch('/email/redeploy-workers', {
|
||||
method: 'POST',
|
||||
|
|
@ -2248,11 +2511,13 @@ async function emailRedeployWorkers() {
|
|||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await dfAlert('Workers redeployed for: ' + (data.domains || []).join(', '), 'Success');
|
||||
emailProgressFinish(true);
|
||||
} else {
|
||||
emailProgressFinish(false);
|
||||
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
emailProgressFinish(false);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,105 +2,133 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">{{ t('email.title') }}</h1>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">{{ t('email.title') }}</h1>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.title_description') }}</p>
|
||||
</div>
|
||||
{% if email_enabled %}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline" onclick="emailRedeployWorkers()">Redeploy Workers</button>
|
||||
<button class="btn btn-primary" onclick="emailOpenWebmail()">{{ t('email.webmail_link') }}</button>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button class="btn btn-outline btn-sm" onclick="emailRedeployWorkers()">Redeploy Workers</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="emailOpenWebmail()">{{ t('email.webmail_link') }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="emailPermissionsBanner" class="alert alert-warning shadow-lg mb-8 hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>
|
||||
<h3 class="font-bold">{{ t('email.permissions_title') }}</h3>
|
||||
<div class="text-sm">
|
||||
<span id="permEmailRouting"></span> {{ t('email.permission_email_routing') }}<br>
|
||||
<span id="permWorkers"></span> {{ t('email.permission_workers') }}<br>
|
||||
<span id="permR2"></span> {{ t('email.permission_r2') }}
|
||||
<h3 class="font-bold">{{ t('email.permissions_title') }}</h3>
|
||||
<div class="text-sm mt-1">
|
||||
<span id="permEmailRouting"></span> {{ t('email.permission_email_routing') }}<br>
|
||||
<span id="permWorkers"></span> {{ t('email.permission_workers') }}<br>
|
||||
<span id="permR2"></span> {{ t('email.permission_r2') }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" onclick="emailCheckPermissions()">{{ t('email.recheck_permissions') }}</button>
|
||||
</div>
|
||||
|
||||
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body overflow-visible">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-base-300 pb-4 mb-6 gap-4">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.domain_setup') }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.domain_setup_description') }}</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<button class="btn btn-sm btn-ghost" onclick="emailCheckPermissions()">{{ t('email.recheck_permissions') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.domain_setup') }}</h2>
|
||||
<div class="form-control w-full max-w-xs mb-4">
|
||||
<label class="label"><span class="label-text">{{ t('email.select_zone') }}</span></label>
|
||||
<select id="emailZoneSelect" class="select select-bordered">
|
||||
<option disabled selected>{{ t('email.choose_domain') }}</option>
|
||||
{% for zone in zones %}
|
||||
<option value="{{ zone.id }}">{{ zone.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button id="emailSetupBtn" class="btn btn-primary max-w-xs" onclick="emailSetupDomain()" disabled>{{ t('email.setup_email') }}</button>
|
||||
|
||||
<div class="overflow-x-auto mt-6">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('email.domain') }}</th>
|
||||
<th>{{ t('email.status') }}</th>
|
||||
<th>{{ t('email.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if email_config.domains %}
|
||||
{% for domain, cfg in email_config.domains.items() %}
|
||||
<tr>
|
||||
<td>{{ domain }}</td>
|
||||
<td><div class="badge badge-success">{{ t('email.setup_complete') }}</div></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="emailVerifyDns('{{ domain }}')">{{ t('email.dns_verify') }}</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="emailRepairDns('{{ domain }}')">Repair DNS</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="emailUpdateR2('{{ domain }}')">R2 Credentials</button>
|
||||
<button class="btn btn-sm btn-warning" onclick="emailTeardownPartial('{{ domain }}')">{{ t('email.teardown_partial') }}</button>
|
||||
<button class="btn btn-sm btn-error" onclick="emailTeardownComplete('{{ domain }}')">{{ t('email.teardown_complete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<div class="flex items-center gap-2 w-full sm:w-auto">
|
||||
<select id="emailZoneSelect" class="select select-bordered select-sm flex-1 sm:w-56">
|
||||
<option disabled selected>{{ t('email.choose_domain') }}</option>
|
||||
{% for zone in zones %}
|
||||
<option value="{{ zone.id }}">{{ zone.name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center">{{ t('email.no_domains') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</select>
|
||||
<button id="emailSetupBtn" class="btn btn-primary btn-sm shrink-0" onclick="emailSetupDomain(event)" disabled>{{ t('email.setup_email') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.mailbox_management') }}</h2>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input type="text" id="newMailboxAddress" placeholder="address" class="input input-bordered w-full max-w-xs" />
|
||||
<span class="self-center">@</span>
|
||||
<select id="newMailboxDomain" class="select select-bordered">
|
||||
{% if email_config and email_config.domains %}
|
||||
{% for domain in email_config.domains %}
|
||||
<option value="{{ domain }}">{{ domain }}</option>
|
||||
<table class="table table-zebra w-full">
|
||||
<colgroup>
|
||||
<col>
|
||||
<col class="w-36">
|
||||
<col class="w-64">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3">{{ t('email.domain') }}</th>
|
||||
<th class="px-4 py-3">{{ t('email.status') }}</th>
|
||||
<th class="px-4 py-3 text-right">{{ t('email.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if email_config.domains %}
|
||||
{% for domain, cfg in email_config.domains.items() %}
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ domain }}</td>
|
||||
<td class="px-4 py-3"><div class="badge badge-success badge-sm">{{ t('email.setup_complete') }}</div></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-sm btn-ghost" onclick="emailVerifyDns('{{ domain }}', event)">{{ t('email.dns_verify') }}</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="emailRepairDns('{{ domain }}', event)">Repair DNS</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-sm btn-ghost px-2">⋮</button>
|
||||
<ul tabindex="0" class="dropdown-content z-50 menu menu-sm p-2 shadow-lg bg-base-100 border border-base-200 rounded-box w-52">
|
||||
<li><button onclick="emailUpdateR2('{{ domain }}', event)">R2 Credentials</button></li>
|
||||
<li class="menu-title">Danger zone</li>
|
||||
<li><button onclick="emailTeardownPartial('{{ domain }}', event)" class="text-warning">{{ t('email.teardown_partial') }}</button></li>
|
||||
<li><button onclick="emailTeardownComplete('{{ domain }}', event)" class="text-error">{{ t('email.teardown_complete') }}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="px-4 py-8 text-center opacity-50">{{ t('email.no_domains') }}</td></tr>
|
||||
{% endif %}
|
||||
</select>
|
||||
<input type="text" id="newMailboxName" placeholder="Display Name" class="input input-bordered w-full max-w-xs" />
|
||||
<button class="btn btn-primary" onclick="emailCreateMailbox()">{{ t('email.add_mailbox') }}</button>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="overflow-x-auto mt-4">
|
||||
<table class="table w-full">
|
||||
{% if email_enabled %}
|
||||
|
||||
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-base-300 pb-4 mb-6 gap-4">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.mailbox_management') }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.mailbox_description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||
<div class="flex items-center gap-1 flex-1 min-w-48">
|
||||
<input type="text" id="newMailboxAddress" placeholder="{{ t('email.address') }}" class="input input-bordered input-sm w-32 flex-1" />
|
||||
<span class="text-sm opacity-40 select-none px-1">@</span>
|
||||
<select id="newMailboxDomain" class="select select-bordered select-sm">
|
||||
{% if email_config and email_config.domains %}
|
||||
{% for domain in email_config.domains %}
|
||||
<option value="{{ domain }}">{{ domain }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" id="newMailboxName" placeholder="{{ t('email.display_name') }}" class="input input-bordered input-sm flex-1 min-w-32" />
|
||||
<button class="btn btn-primary btn-sm shrink-0" onclick="emailCreateMailbox(event)">{{ t('email.add_mailbox') }}</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<colgroup>
|
||||
<col>
|
||||
<col class="w-36">
|
||||
<col class="w-32">
|
||||
<col class="w-40">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('email.address') }}</th>
|
||||
<th>{{ t('email.display_name') }}</th>
|
||||
<th>{{ t('email.domain') }}</th>
|
||||
<th>{{ t('email.actions') }}</th>
|
||||
<th class="px-4 py-3">{{ t('email.address') }}</th>
|
||||
<th class="px-4 py-3">{{ t('email.display_name') }}</th>
|
||||
<th class="px-4 py-3">{{ t('email.domain') }}</th>
|
||||
<th class="px-4 py-3 text-right">{{ t('email.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -108,102 +136,107 @@
|
|||
{% for domain, cfg in email_config.domains.items() %}
|
||||
{% for addr, mb in cfg.mailboxes.items() %}
|
||||
<tr>
|
||||
<td>{{ addr }}</td>
|
||||
<td>{{ mb.display_name }}</td>
|
||||
<td>{{ domain }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline" onclick="emailSetPassword('{{ addr }}', '{{ domain }}')">Set Password</button>
|
||||
<button class="btn btn-sm btn-error" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}')">{{ t('email.delete') }}</button>
|
||||
<td class="px-4 py-3 font-mono text-sm">{{ addr }}</td>
|
||||
<td class="px-4 py-3">{{ mb.display_name }}</td>
|
||||
<td class="px-4 py-3 opacity-50 text-sm">{{ domain }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-sm btn-outline" onclick="emailSetPassword('{{ addr }}', '{{ domain }}')">Set Password</button>
|
||||
<button class="btn btn-sm btn-error btn-outline" onclick="emailDeleteMailbox('{{ addr }}', '{{ domain }}', event)">{{ t('email.delete') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center opacity-50">{{ t('email.no_mailboxes') }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.dns_records') }}</h2>
|
||||
<div id="dnsRecordsContainer">
|
||||
<div class="border-b border-base-300 pb-4 mb-6">
|
||||
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.statistics') }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.statistics_description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.statistics') }}</h2>
|
||||
<div class="stats shadow">
|
||||
<div class="stats stats-horizontal shadow w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_received') }}</div>
|
||||
<div class="stat-value" id="statReceived">-</div>
|
||||
<div class="stat-value text-primary" id="statReceived">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_sent') }}</div>
|
||||
<div class="stat-value" id="statSent">-</div>
|
||||
<div class="stat-value text-secondary" id="statSent">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_storage') }}</div>
|
||||
<div class="stat-value" id="statStorage">-</div>
|
||||
<div class="stat-value text-accent" id="statStorage">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">{{ t('email.stats_mailboxes') }}</div>
|
||||
<div class="stat-value" id="statMailboxes">-</div>
|
||||
<div class="stat-value text-info" id="statMailboxes">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.backup_restore') }}</h2>
|
||||
<div class="divider mt-0 mb-4"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ t('email.backup_title') }}</h3>
|
||||
<p class="text-sm opacity-80 mb-2">{{ t('email.backup_description') }}</p>
|
||||
<div class="badge badge-warning mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.backup_security_warning') }}</div>
|
||||
<br>
|
||||
<a href="{{ url_for('email.email_backup') }}" class="btn btn-primary btn-sm">{{ t('email.download_backup') }}</a>
|
||||
<div class="border-b border-base-300 pb-4 mb-6">
|
||||
<h2 class="card-title text-2xl sm:text-3xl">{{ t('email.backup_restore') }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.backup_restore_description') }}</p>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ t('email.restore_title') }}</h3>
|
||||
<p class="text-sm opacity-80 mb-2">{{ t('email.restore_description') }}</p>
|
||||
<div class="badge badge-error mb-4 p-3 h-auto whitespace-normal text-left">{{ t('email.restore_warning') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input type="file" id="emailRestoreFile" accept=".zip" class="file-input file-input-bordered file-input-sm w-full max-w-xs" />
|
||||
<button class="btn btn-error btn-sm" id="emailRestoreBtn" onclick="emailRestoreBackup()">{{ t('email.restore_backup') }}</button>
|
||||
<div class="grid sm:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{{ t('email.backup_title') }}</h3>
|
||||
<p class="text-sm opacity-60 mb-3">{{ t('email.backup_description') }}</p>
|
||||
<p class="text-xs text-warning font-medium mb-4">⚠ {{ t('email.backup_security_warning') }}</p>
|
||||
<a href="{{ url_for('email.email_backup') }}" class="btn btn-primary btn-sm">{{ t('email.download_backup') }}</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold mb-1">{{ t('email.restore_title') }}</h3>
|
||||
<p class="text-sm opacity-60 mb-3">{{ t('email.restore_description') }}</p>
|
||||
<p class="text-xs text-error font-medium mb-4">⚠ {{ t('email.restore_warning') }}</p>
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="btn btn-outline btn-sm" for="emailRestoreFile">Choose file</label>
|
||||
<span id="emailRestoreFileName" class="text-sm opacity-50 truncate flex-1">No file chosen</span>
|
||||
<input type="file" id="emailRestoreFile" accept=".zip" class="hidden" onchange="document.getElementById('emailRestoreFileName').textContent = this.files[0]?.name || 'No file chosen'" />
|
||||
<button class="btn btn-error btn-sm shrink-0" id="emailRestoreBtn" onclick="emailRestoreBackup()">{{ t('email.restore_backup') }}</button>
|
||||
</div>
|
||||
<p id="emailRestoreFeedback" class="text-sm mt-2 font-semibold hidden"></p>
|
||||
</div>
|
||||
<p id="emailRestoreFeedback" class="text-sm mt-2 font-semibold hidden"></p>
|
||||
</div>
|
||||
<div id="emailOrphanedSection" class="hidden">
|
||||
<div id="emailOrphanedSection" class="hidden mt-6">
|
||||
<div class="divider"></div>
|
||||
<h3 class="text-lg font-semibold">{{ t('email.orphaned_data_title') }}</h3>
|
||||
<p class="text-sm opacity-80 mb-2">{{ t('email.orphaned_data_description') }}</p>
|
||||
<h3 class="font-semibold mb-1">{{ t('email.orphaned_data_title') }}</h3>
|
||||
<p class="text-sm opacity-60 mb-3">{{ t('email.orphaned_data_description') }}</p>
|
||||
<div id="emailOrphanedList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-error mb-8">
|
||||
<section class="card bg-base-100 shadow-xl border border-error mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-error">{{ t('email.nuke_all_title') }}</h2>
|
||||
<div class="divider mt-0 mb-4"></div>
|
||||
<p class="text-sm opacity-80 mb-4">{{ t('email.nuke_all_description') }}</p>
|
||||
<div class="border-b border-error border-opacity-30 pb-4 mb-6">
|
||||
<h2 class="card-title text-2xl sm:text-3xl text-error">{{ t('email.nuke_all_title') }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ t('email.nuke_all_description') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-warning btn-sm" onclick="emailNukeAll(false)">{{ t('email.nuke_all_partial') }}</button>
|
||||
<button class="btn btn-error btn-sm" onclick="emailNukeAll(true)">{{ t('email.nuke_all_complete') }}</button>
|
||||
<button class="btn btn-warning btn-sm" onclick="emailNukeAll(false, event)">{{ t('email.nuke_all_partial') }}</button>
|
||||
<button class="btn btn-error btn-sm" onclick="emailNukeAll(true, event)">{{ t('email.nuke_all_complete') }}</button>
|
||||
</div>
|
||||
<p id="emailNukeFeedback" class="text-sm mt-2 font-semibold hidden"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
{% else %}
|
||||
|
||||
<section class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ t('email.container_status') }}</h2>
|
||||
<div class="alert alert-info">
|
||||
<div>
|
||||
<span>{{ t('email.container_stopped') }}</span><br>
|
||||
|
|
@ -211,8 +244,41 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<dialog id="emailSetPasswordModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-1">Set Password</h3>
|
||||
<p class="text-sm opacity-60 mb-4" id="emailSetPasswordTarget"></p>
|
||||
<div class="space-y-3">
|
||||
<input type="password" id="emailSetPasswordNew" placeholder="New password (min 8 characters)" class="input input-bordered w-full" autocomplete="new-password" />
|
||||
<input type="password" id="emailSetPasswordConfirm" placeholder="Confirm password" class="input input-bordered w-full" autocomplete="new-password" />
|
||||
<p id="emailSetPasswordError" class="text-error text-sm hidden"></p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog"><button class="btn btn-ghost">Cancel</button></form>
|
||||
<button class="btn btn-primary" id="emailSetPasswordSubmitBtn">Set Password</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
<div id="emailProgressPanel" class="fixed bottom-4 right-4 w-80 z-50 transition-all duration-300 translate-y-4 opacity-0 pointer-events-none">
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body p-4 gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-bold text-sm" id="emailProgressTitle"></h3>
|
||||
<button class="btn btn-ghost btn-xs btn-circle" id="emailProgressClose">✕</button>
|
||||
</div>
|
||||
<ul id="emailProgressSteps" class="space-y-1 text-sm"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>document.addEventListener('DOMContentLoaded', () => { emailCheckPermissions(); emailLoadOrphanedDomains(); });</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -72,14 +72,18 @@ def setup_email_domain():
|
|||
zone_name = data.get('zone_name')
|
||||
if not zone_id or not zone_name:
|
||||
return jsonify({'success': False, 'error': 'Missing zone info'}), 400
|
||||
|
||||
try:
|
||||
try:
|
||||
email_manager.enable_email_routing(zone_id)
|
||||
except Exception as routing_err:
|
||||
logging.warning(f"Could not enable email routing via API (may need manual enable in CF Dashboard): {routing_err}")
|
||||
|
||||
email_manager.setup_email_dns_records(zone_id, zone_name)
|
||||
|
||||
bucket_name = f"dockflare-mail-{zone_name.replace('.', '-')}"
|
||||
email_manager.create_r2_bucket(bucket_name)
|
||||
|
||||
r2_creds = email_manager.get_r2_s3_credentials()
|
||||
r2_access_key_id = r2_creds['access_key_id']
|
||||
r2_secret_access_key = r2_creds['secret_access_key']
|
||||
|
|
@ -91,11 +95,10 @@ def setup_email_domain():
|
|||
email_cfg['enabled'] = True
|
||||
if 'domains' not in email_cfg:
|
||||
email_cfg['domains'] = {}
|
||||
|
||||
|
||||
if 'jwt_signing_key' not in email_cfg:
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key()
|
||||
|
||||
private_bytes = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
|
|
@ -136,6 +139,7 @@ def setup_email_domain():
|
|||
{"type": "plain_text", "name": "ALLOWED_RECIPIENTS", "text": "[]"},
|
||||
{"type": "plain_text", "name": "DOMAIN_NAME", "text": zone_name}
|
||||
]
|
||||
|
||||
email_manager.deploy_worker(inbound_worker_name, _read_worker_template('inbound_worker.js'), inbound_bindings)
|
||||
email_manager.set_worker_cron(inbound_worker_name, ['*/5 * * * *'])
|
||||
email_manager.setup_catchall_routing_rule(zone_id, inbound_worker_name)
|
||||
|
|
@ -144,6 +148,7 @@ def setup_email_domain():
|
|||
{"type": "send_email", "name": "SEND_EMAIL"},
|
||||
{"type": "secret_text", "name": "AUTH_SECRET", "text": outbound_auth_secret}
|
||||
]
|
||||
|
||||
email_manager.deploy_worker(outbound_worker_name, _read_worker_template('outbound_worker.js'), outbound_bindings)
|
||||
|
||||
outbound_worker_url = f"https://{outbound_worker_name}.{workers_subdomain}.workers.dev" if workers_subdomain else ''
|
||||
|
|
@ -167,6 +172,7 @@ def setup_email_domain():
|
|||
save_email_config(email_cfg)
|
||||
config.EMAIL_ENABLED = True
|
||||
current_app.config['EMAIL_ENABLED'] = True
|
||||
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
|
|
@ -239,8 +245,11 @@ def teardown_domain():
|
|||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' not in email_cfg or zone_name not in email_cfg['domains']:
|
||||
return jsonify({'success': False, 'error': 'Domain not found'}), 404
|
||||
|
||||
domain_cfg = email_cfg['domains'][zone_name]
|
||||
|
||||
errors = _teardown_domain_remote(zone_name, domain_cfg)
|
||||
|
||||
if include_local:
|
||||
token = _generate_jwt(current_user.get_id(), role='admin')
|
||||
if token:
|
||||
|
|
@ -255,11 +264,13 @@ def teardown_domain():
|
|||
errors.append(f"Local wipe: {resp.text}")
|
||||
except Exception as e:
|
||||
errors.append(f"Local wipe: {e}")
|
||||
|
||||
del email_cfg['domains'][zone_name]
|
||||
if not email_cfg.get('domains'):
|
||||
config.EMAIL_ENABLED = False
|
||||
current_app.config['EMAIL_ENABLED'] = False
|
||||
save_email_config(email_cfg)
|
||||
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True, 'errors': errors})
|
||||
|
||||
|
|
@ -271,8 +282,10 @@ def teardown_all():
|
|||
include_local = data.get('include_local_data', False)
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
errors = []
|
||||
|
||||
for domain, domain_cfg in list(email_cfg.get('domains', {}).items()):
|
||||
errors.extend(_teardown_domain_remote(domain, domain_cfg))
|
||||
|
||||
if include_local:
|
||||
token = _generate_jwt(current_user.get_id(), role='admin')
|
||||
if token:
|
||||
|
|
@ -286,10 +299,12 @@ def teardown_all():
|
|||
errors.append(f"Local wipe-all: {resp.text}")
|
||||
except Exception as e:
|
||||
errors.append(f"Local wipe-all: {e}")
|
||||
|
||||
email_cfg['domains'] = {}
|
||||
config.EMAIL_ENABLED = False
|
||||
current_app.config['EMAIL_ENABLED'] = False
|
||||
save_email_config(email_cfg)
|
||||
|
||||
_restart_mail_container()
|
||||
return jsonify({'success': True, 'errors': errors})
|
||||
|
||||
|
|
@ -383,7 +398,6 @@ def create_mailbox():
|
|||
try:
|
||||
res = email_manager.create_email_routing_rule(zone_id, address, worker_name)
|
||||
rule_id = res.get('result', {}).get('id', '')
|
||||
|
||||
email_cfg['domains'][domain]['mailboxes'][address] = {
|
||||
'display_name': display_name,
|
||||
'routing_rule_id': rule_id,
|
||||
|
|
@ -402,7 +416,7 @@ def delete_mailbox():
|
|||
data = request.get_json(force=True, silent=True) or {}
|
||||
address = data.get('address')
|
||||
domain = data.get('domain')
|
||||
|
||||
|
||||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' in email_cfg and domain in email_cfg['domains']:
|
||||
if address in email_cfg['domains'][domain]['mailboxes']:
|
||||
|
|
@ -447,6 +461,7 @@ def update_r2_credentials():
|
|||
email_cfg = config.EMAIL_CONFIG.copy()
|
||||
if 'domains' not in email_cfg or zone_name not in email_cfg['domains']:
|
||||
return jsonify({'success': False, 'error': 'Domain not configured'}), 404
|
||||
|
||||
email_cfg['domains'][zone_name]['r2_access_key_id'] = access_key_id
|
||||
email_cfg['domains'][zone_name]['r2_secret_access_key'] = secret_access_key
|
||||
save_email_config(email_cfg)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,18 @@ module.exports = {
|
|||
'inline-block',
|
||||
'mr-1',
|
||||
'text-info',
|
||||
'bottom-4',
|
||||
'right-4',
|
||||
'w-80',
|
||||
'z-50',
|
||||
'translate-y-4',
|
||||
'translate-y-0',
|
||||
'opacity-0',
|
||||
'opacity-100',
|
||||
'pointer-events-none',
|
||||
'pointer-events-auto',
|
||||
'space-y-1',
|
||||
'shrink-0',
|
||||
],
|
||||
daisyui: {
|
||||
themes: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue