email UI - overhaul - progress information for UI

This commit is contained in:
ChrispyBacon-dev 2026-04-14 10:27:10 +02:00
parent 12bf5e418e
commit 2e4b2ca7e7
7 changed files with 589 additions and 198 deletions

View file

@ -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"
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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 %}

View file

@ -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)

View file

@ -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: [