added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+44
View File
@@ -0,0 +1,44 @@
{{define "title"}}Add Mailbox - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header"><h4 class="mb-0"><i class="bi bi-mailbox me-2"></i>Add New Mailbox</h4></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="local_part" class="form-label">Email Address</label>
<div class="input-group">
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="user"
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
<span class="input-group-text">@</span>
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
</div>
<div class="form-text">This is the mailbox's login username — it never changes, even if aliases are added later.</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required minlength="8">
<div class="form-text">Used for the self-service account portal only — never for IMAP/SMTP clients. Those use a separate app password (create one after adding this mailbox).</div>
</div>
<div class="mb-4">
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" placeholder="5">
<div class="form-text">Leave blank to use the domain's default quota.</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
<button type="submit" class="btn btn-success"><i class="bi bi-mailbox me-2"></i>Add Mailbox</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
+64 -1
View File
@@ -5,6 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Email Server Management{{end}}</title>
<script>
// Applied before first paint so an unpinned sidebar starts collapsed, not
// pinned-then-flashing-collapsed once the stylesheet/JS below catches up.
if (localStorage.getItem('sidebarPinned') === 'false') {
document.documentElement.classList.add('sidebar-unpinned');
}
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
@@ -13,6 +21,17 @@
body { background-color: #1a1a1a; color: #e0e0e0; }
.main-container { display: flex; min-height: 100vh; }
.content-area { flex: 1; margin-left: var(--sidebar-width); padding: 20px; transition: margin-left 0.3s ease; }
/* Unpinned sidebar: collapsed off-screen, floats over full-width content until
pinned again. Revealed by the toggle button or by moving the mouse to the
left edge (see JS below); hidden again on mouseleave. */
html.sidebar-unpinned .sidebar { transform: translateX(-100%); }
html.sidebar-unpinned .sidebar.sidebar-open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5); }
html.sidebar-unpinned .content-area { margin-left: 0 !important; }
/* Sits in the page-title bar, not fixed over the page — so when the sidebar
peeks open (z-index above content), it naturally covers this button instead
of the button floating on top of the sidebar's own header. */
.sidebar-toggle-btn { display: none; }
html.sidebar-unpinned .sidebar-toggle-btn { display: inline-flex; }
.navbar-brand { color: #fff !important; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
@@ -52,7 +71,10 @@
<div class="content-area">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="navbar-brand mb-0 h1 d-flex align-items-center">
<button id="sidebarToggleBtn" class="btn btn-sm btn-outline-light me-2 sidebar-toggle-btn" title="Show sidebar" onclick="showSidebarPeek()">
<i class="bi bi-list"></i>
</button>
<i class="bi bi-envelope-fill me-2"></i>
{{block "page_title" .}}Email Server Management{{end}}
</span>
@@ -118,6 +140,47 @@
setInterval(updateTime, 1000);
updateTime();
// Sidebar pin/unpin — pinned (default) keeps today's always-visible behavior.
// Unpinned collapses it off-screen so the content area gets the full width;
// it re-appears as an overlay via the toggle button or by nudging the mouse to
// the left edge, and hides again once the mouse leaves it.
function isSidebarPinned() { return localStorage.getItem('sidebarPinned') !== 'false'; }
function updateSidebarPinUI() {
const icon = document.getElementById('sidebarPinIcon');
const btn = document.getElementById('sidebarPinBtn');
if (!icon || !btn) return;
const pinned = isSidebarPinned();
icon.className = pinned ? 'bi bi-pin-angle-fill' : 'bi bi-pin-angle';
btn.title = pinned ? 'Unpin sidebar (auto-hide)' : 'Pin sidebar (keep open)';
}
function hideSidebarPeek() {
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) sidebarEl.classList.remove('sidebar-open');
}
function showSidebarPeek() {
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) sidebarEl.classList.add('sidebar-open');
}
function toggleSidebarPin() {
const pinned = !isSidebarPinned();
localStorage.setItem('sidebarPinned', pinned ? 'true' : 'false');
document.documentElement.classList.toggle('sidebar-unpinned', !pinned);
updateSidebarPinUI();
hideSidebarPeek();
}
document.addEventListener('DOMContentLoaded', function() {
updateSidebarPinUI();
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) {
sidebarEl.addEventListener('mouseleave', function() {
if (!isSidebarPinned()) hideSidebarPeek();
});
}
document.addEventListener('mousemove', function(e) {
if (!isSidebarPinned() && e.clientX <= 15) showSidebarPeek();
});
});
// Notifications auto-dismiss after 5s, but hovering (reading, or selecting
// text to copy) pauses the timer — it only resumes once the mouse leaves.
// Clicking inside never dismisses; only the X button or the timer does.
+21
View File
@@ -54,6 +54,27 @@
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/mailboxes" class="text-decoration-none">
<div class="card {{if gt .mailboxes_near_quota 0}}border-danger{{else}}border-primary{{end}}">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} mb-1"><i class="bi bi-inbox me-2"></i>Mailboxes</h5>
<h3 class="mb-0">{{.mailbox_count}}</h3>
{{if gt .mailboxes_near_quota 0}}
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{.mailboxes_near_quota}} near quota</small>
{{else}}
<small class="text-muted">Active mailboxes</small>
{{end}}
</div>
<div class="fs-2 {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} opacity-50"><i class="bi bi-inbox"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<div class="card border-info">
<div class="card-body">
@@ -0,0 +1,55 @@
{{define "title"}}Edit Mailbox - SMTP Management{{end}}
{{define "content"}}
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-mailbox2 me-2"></i>Edit Mailbox</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Email Address</label>
<input type="text" class="form-control" value="{{.mailbox.Email}}" disabled>
<div class="form-text">The address can't be changed here — app passwords and stored mail are bound to it. Remove and recreate the mailbox instead.</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Leave blank to keep current password">
<div class="form-text">Only enter a password if you want to change it — used for the self-service portal, never IMAP/SMTP.</div>
</div>
<div class="mb-3">
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" value="{{printf "%.2f" .quota_gb}}">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Mailbox</button>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-x-lg me-1"></i>Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Current Mailbox Details</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Email:</dt><dd class="col-sm-7"><code>{{.mailbox.Email}}</code></dd>
<dt class="col-sm-5">Domain:</dt>
<dd class="col-sm-7">{{range .domains}}{{if eq .ID $.mailbox.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">{{if .mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}</dd>
<dt class="col-sm-5">Storage:</dt>
<dd class="col-sm-7"><small class="text-muted">{{filesize .mailbox.UsedBytes}} / {{filesize .mailbox.QuotaBytes}}</small></dd>
<dt class="col-sm-5">Created:</dt><dd class="col-sm-7"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .mailbox.CreatedAt}}</small></dd>
</dl>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-key me-1"></i>Manage App Passwords</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-signpost-split me-1"></i>Manage Aliases</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-shield-exclamation me-1"></i>Allow/Block List</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-funnel me-1"></i>Filter Rules</a>
</div>
</div>
</div>
</div>
{{end}}
+11 -11
View File
@@ -7,12 +7,10 @@
<a href="/pymta-manager/ips/add" class="btn btn-success"><i class="bi bi-plus-circle me-2"></i>Add IP Address</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
<div class="card-body">
{{if .ips}}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
<div class="card-body">
{{if .ips}}
<div class="table-responsive">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Domain</th><th>Status</th><th>Storage Type</th><th>Added</th><th>Actions</th></tr></thead>
@@ -56,14 +54,14 @@
</div>
{{end}}
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>IP Whitelist Information</h6></div>
<div class="card-body">
<div class="alert alert-info">
<div class="alert alert-info mb-0">
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>How IP Whitelisting Works</h6>
<ul class="mb-0 small">
<li>Whitelisted IPs can send emails without username/password authentication</li>
@@ -74,8 +72,10 @@
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-geo-alt me-2"></i>Your Current IP</h6></div>
<div class="card-body">
<div class="text-center">
+173
View File
@@ -0,0 +1,173 @@
{{define "title"}}Let's Encrypt - Email Server Management{{end}}
{{define "page_title"}}Let's Encrypt{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9">
{{if .status.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span>
{{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span>
{{end}}
</dd>
<dt class="col-sm-3">Domains</dt>
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
<dt class="col-sm-3">Provider</dt>
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
<dt class="col-sm-3">Certificate expires</dt>
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
<dt class="col-sm-3">Last attempt</dt>
<dd class="col-sm-9">
{{if .status.LastAttempt.IsZero}}
<span class="text-muted">none yet this run</span>
{{else if .status.LastError}}
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — {{.status.LastError}}</span>
{{else}}
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — success</span>
{{end}}
</dd>
</dl>
<form method="post" action="/pymta-manager/letsencrypt/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
</form>
</div>
</div>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Enable Let's Encrypt</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</option>
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
<div class="form-text">Recommended while testing a new configuration.</div>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
</div>
<div class="mb-3">
<label class="form-label">DNS Provider</label>
<select class="form-select" name="dns_provider" id="le_provider">
<option value="">Select a provider...</option>
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
</select>
</div>
<div class="provider-fields" id="fields-cloudflare">
<div class="setting-section mb-3">
<h6>Cloudflare</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-route53">
<div class="setting-section mb-3">
<h6>AWS Route53</h6>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
</div>
<div class="mb-3">
<label class="form-label">Region</label>
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
</div>
<div class="mb-3">
<label class="form-label">Hosted Zone ID (optional)</label>
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
</div>
</div>
</div>
<div class="provider-fields" id="fields-digitalocean">
<div class="setting-section mb-3">
<h6>DigitalOcean</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-gcloud">
<div class="setting-section mb-3">
<h6>Google Cloud DNS</h6>
<div class="mb-3">
<label class="form-label">Project ID</label>
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
</div>
<div class="mb-3">
<label class="form-label">Service Account Key (optional)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save Configuration</button>
</div>
</div>
</form>
{{end}}
{{define "extra_js"}}
<script>
function updateProviderFields() {
const selected = document.getElementById('le_provider').value;
document.querySelectorAll('.provider-fields').forEach(function(el) {
el.style.display = (el.id === 'fields-' + selected) ? '' : 'none';
});
}
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
updateProviderFields();
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('gcloud_key_file', file);
fetch('/pymta-manager/api/letsencrypt/upload_gcloud_key', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.getElementById('gcloud_sa_path').value = data.filepath; showToast('Service account key uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload key', 'danger'); }
}).catch(() => showToast('Failed to upload key', 'danger'));
});
</script>
{{end}}
@@ -0,0 +1,76 @@
{{define "title"}}Aliases - Email Server{{end}}
{{define "page_title"}}Aliases{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-signpost-split me-2"></i>Aliases <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>An alias lets this mailbox receive mail at another address. Enable "send as" to also let it send mail using that address. The login address is always <code>{{.mailbox.Email}}</code> — aliases never change that.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Alias</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases/add">
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="alias"
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
<span class="input-group-text">@</span>
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as" name="can_send_as">
<label class="form-check-label" for="can_send_as"><strong>Allow sending as this address</strong></label>
<div class="form-text">If enabled, this mailbox can use MAIL FROM with this alias once authenticated with its app password.</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-signpost-split me-2"></i>Add Alias</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Aliases</h5></div>
<div class="card-body p-0">
{{if .aliases}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Address</th><th>Permissions</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .aliases}}
<tr>
<td>{{.Email}}</td>
<td>
{{if .CanSendAs}}<span class="badge bg-warning text-dark"><i class="bi bi-send me-1"></i>Receive &amp; Send</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-inbox me-1"></i>Receive Only</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/aliases/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove alias {{.Email}}?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-signpost-split text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No aliases yet</h4>
<p class="text-muted">Add one above to let this mailbox receive mail at another address.</p>
</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,62 @@
{{define "title"}}App Passwords - Email Server{{end}}
{{define "page_title"}}App Passwords{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-key me-2"></i>App Passwords <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Create App Password</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords/add" class="row g-2 align-items-end">
<div class="col-auto">
<label for="label" class="form-label">Label</label>
<input type="text" class="form-control" id="label" name="label" placeholder="e.g. Thunderbird laptop">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing App Passwords</h5></div>
<div class="card-body p-0">
{{if .passwords}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .passwords}}
<tr>
<td>{{.Label}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Revoked</span>{{end}}</td>
<td>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/apppasswords/{{.ID}}/revoke" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Revoke" data-confirm="Revoke app password &quot;{{.Label}}&quot;? Any client using it will stop working."><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-key text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No app passwords yet</h4>
<p class="text-muted">Create one above to connect a mail client to this mailbox.</p>
</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,69 @@
{{define "title"}}Allow/Block List - Email Server{{end}}
{{define "page_title"}}Allow/Block List{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-exclamation me-2"></i>Allow/Block List <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>A pattern is either an exact address (<code>spam@evil.com</code>) or a whole domain (<code>@evil.com</code>). Block entries reject mail at RCPT time; allow entries bypass spam scoring entirely for that sender.
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0 text-success"><i class="bi bi-check-circle me-2"></i>Allow List</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
<input type="hidden" name="list_type" value="allow">
<input type="text" class="form-control" name="pattern" placeholder="friend@example.com or @example.com" required>
<button type="submit" class="btn btn-success"><i class="bi bi-plus-lg"></i></button>
</form>
{{if .allow}}
<ul class="list-group list-group-flush">
{{range .allow}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
<code>{{.Pattern}}</code>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the allow list?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted mb-0">No allow-list entries.</p>
{{end}}
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0 text-danger"><i class="bi bi-x-circle me-2"></i>Block List</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
<input type="hidden" name="list_type" value="block">
<input type="text" class="form-control" name="pattern" placeholder="spam@evil.com or @evil.com" required>
<button type="submit" class="btn btn-danger"><i class="bi bi-plus-lg"></i></button>
</form>
{{if .block}}
<ul class="list-group list-group-flush">
{{range .block}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
<code>{{.Pattern}}</code>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the block list?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted mb-0">No block-list entries.</p>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+104
View File
@@ -0,0 +1,104 @@
{{define "title"}}Filter Rules - Email Server{{end}}
{{define "page_title"}}Filter Rules{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-funnel me-2"></i>Filter Rules <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins. "Move to folder" delivers into a separate IMAP folder instead of INBOX — your mail client will show it once mail has actually landed there.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Priority</label>
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
</div>
<div class="col-auto">
<label class="form-label">If</label>
<select class="form-select" name="condition_field">
<option value="from">From</option>
<option value="to">To</option>
<option value="subject">Subject</option>
</select>
</div>
<div class="col-auto">
<select class="form-select" name="condition_op">
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="starts_with">starts with</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
</div>
<div class="col-auto">
<label class="form-label">Then</label>
<select class="form-select" name="action" id="rule_action">
<option value="move_to_folder">Move to folder</option>
<option value="delete">Delete</option>
<option value="mark_read">Mark as read</option>
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h5></div>
<div class="card-body p-0">
{{if .rules}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Priority</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .rules}}
<tr>
<td>{{.Priority}}</td>
<td><code>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-secondary">Inactive</span>{{end}}</td>
<td>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/rules/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-funnel text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No rules yet</h4>
<p class="text-muted">Add one above to automatically sort or act on incoming mail.</p>
</div>
{{end}}
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.getElementById('rule_action').addEventListener('change', function(e) {
const valueInput = document.getElementById('rule_action_value');
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
});
</script>
{{end}}
+70
View File
@@ -0,0 +1,70 @@
{{define "title"}}Mailboxes - Email Server Management{{end}}
{{define "page_title"}}Mailbox Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-inbox me-2"></i>Mailboxes</h2>
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Mailboxes</h5></div>
<div class="card-body p-0">
{{if .mailboxes}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Domain</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .mailboxes}}
{{$mailbox := index . 0}}{{$extra := index . 1}}
<tr>
<td><div class="fw-bold">{{$mailbox.Email}}</div></td>
<td><span class="badge bg-secondary">{{$extra.domain_name}}</span></td>
<td>
{{if $mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}
</td>
<td>
<small class="text-muted">{{filesize $mailbox.UsedBytes}} / {{filesize $mailbox.QuotaBytes}}</small>
{{if ge $extra.pct_full 90.0}}
<span class="badge bg-danger ms-1"><i class="bi bi-exclamation-octagon me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
{{else if ge $extra.pct_full 75.0}}
<span class="badge bg-warning text-dark ms-1"><i class="bi bi-exclamation-triangle me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $mailbox.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm" title="App Passwords"><i class="bi bi-key"></i></a>
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm" title="Aliases"><i class="bi bi-signpost-split"></i></a>
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Mailbox"><i class="bi bi-pencil"></i></a>
{{if $mailbox.IsActive}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Mailbox" data-confirm="Disable mailbox {{$mailbox.Email}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable Mailbox" data-confirm="Enable mailbox {{$mailbox.Email}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove Mailbox" data-confirm="Permanently remove mailbox {{$mailbox.Email}} and all its stored mail? This cannot be undone!"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No mailboxes configured</h4>
<p class="text-muted">Add a mailbox to let a client app (Thunderbird, etc.) receive mail via IMAP</p>
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Your First Mailbox</a>
</div>
{{end}}
</div>
</div>
{{end}}
+26 -6
View File
@@ -1,12 +1,17 @@
{{define "sidebar_email.html"}}
<nav class="sidebar bg-dark border-end border-secondary position-fixed h-100" style="width: var(--sidebar-width); z-index: 1000;">
<div class="d-flex flex-column h-100">
<div class="p-3 border-bottom border-secondary">
<h5 class="text-white mb-0">
<i class="bi bi-server me-2"></i>
SMTP Server
</h5>
<small class="text-muted">Management Console</small>
<div class="p-3 border-bottom border-secondary d-flex align-items-start justify-content-between">
<div>
<h5 class="text-white mb-0">
<i class="bi bi-server me-2"></i>
SMTP Server
</h5>
<small class="text-muted">Management Console</small>
</div>
<button id="sidebarPinBtn" class="btn btn-sm btn-outline-secondary" title="Unpin sidebar (auto-hide)" onclick="toggleSidebarPin()">
<i class="bi bi-pin-angle-fill" id="sidebarPinIcon"></i>
</button>
</div>
<div class="flex-grow-1 overflow-auto">
@@ -41,6 +46,14 @@
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/mailboxes" class="nav-link text-white {{if eq (dget . "active") "mailboxes"}}active{{end}}">
<i class="bi bi-inbox me-2"></i>
Mailboxes
<span class="badge bg-secondary ms-auto">{{dget . "mailbox_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/ips" class="nav-link text-white {{if eq (dget . "active") "ips"}}active{{end}}">
<i class="bi bi-router me-2"></i>
@@ -57,6 +70,13 @@
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/letsencrypt" class="nav-link text-white {{if eq (dget . "active") "letsencrypt"}}active{{end}}">
<i class="bi bi-patch-check me-2"></i>
Let's Encrypt
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
<i class="bi bi-journal-text me-2"></i>
@@ -0,0 +1,273 @@
{{define "webmail_account.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.mailbox.Email}} - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
<form method="post" action="/webmail/logout" class="ms-auto">
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
</form>
</div>
</nav>
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
{{range .flashes}}
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
{{.Message}}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{{end}}
</div>
<div class="container pb-5">
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd me-2"></i>Storage</h5></div>
<div class="card-body">
<div class="progress mb-2" style="height: 1.25rem;">
<div class="progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style="width: {{printf "%.0f" .pct_full}}%">{{printf "%.0f" .pct_full}}%</div>
</div>
<small class="text-muted">{{filesize .mailbox.UsedBytes}} of {{filesize .mailbox.QuotaBytes}} used</small>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/account/password">
<div class="mb-3">
<label class="form-label">Current Password</label>
<input type="password" class="form-control" name="current_password" required>
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="new_password" required minlength="10">
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" name="new_password_confirm" required>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Password</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Two-Factor Authentication</h5></div>
<div class="card-body">
<h6>Authenticator App</h6>
{{if .mailbox.TOTPEnabled}}
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
<form method="post" action="/webmail/account/totp/disable">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Disable authenticator app MFA?">Disable</button>
</form>
{{else}}
<p class="text-muted">Not enabled.</p>
<form method="post" action="/webmail/account/totp/setup">
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-qr-code me-1"></i>Set Up</button>
</form>
{{end}}
<hr>
<h6>Passkeys</h6>
{{if .passkeys}}
<ul class="list-group list-group-flush mb-3">
{{range .passkeys}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
{{.Name}}
<form method="post" action="/webmail/account/passkey/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove passkey &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted">No passkeys registered.</p>
{{end}}
<button type="button" class="btn btn-outline-primary btn-sm" id="passkey-add-btn"><i class="bi bi-fingerprint me-1"></i>Add a Passkey</button>
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>App Passwords</h5></div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
</div>
<form method="POST" action="/webmail/account/apppasswords/add" class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label">Label</label>
<input type="text" class="form-control" name="label" placeholder="e.g. Thunderbird laptop">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
</div>
</form>
{{if .passwords}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Last Used</th><th></th></tr></thead>
<tbody>
{{range .passwords}}
<tr>
<td>{{.Label}}</td>
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
<td>
<form method="post" action="/webmail/account/apppasswords/{{.ID}}/revoke" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Revoke app password &quot;{{.Label}}&quot;?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No app passwords yet.</p>
{{end}}
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
const TOAST_AUTOHIDE_MS = 5000;
function armToastAutoDismiss(toastEl, bsToast) {
let timer = null;
const start = () => { timer = setTimeout(() => bsToast.hide(), TOAST_AUTOHIDE_MS); };
const stop = () => { if (timer) { clearTimeout(timer); timer = null; } };
toastEl.addEventListener('mouseenter', stop);
toastEl.addEventListener('mouseleave', start);
start();
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) {
const toast = new bootstrap.Toast(el);
toast.show();
armToastAutoDismiss(el, toast);
});
});
function showConfirmation(message) {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
document.getElementById('confirmationModalBody').textContent = message;
const confirmButton = document.getElementById('confirmationModalConfirm');
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
const handleCancel = () => { resolve(false); cleanup(); };
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
modal.removeEventListener('hidden.bs.modal', handleCancel);
};
confirmButton.addEventListener('click', handleConfirm);
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
new bootstrap.Modal(modal).show();
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('[data-confirm]').forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
if (await showConfirmation(this.getAttribute('data-confirm'))) {
const form = this.closest('form');
if (form) form.submit();
}
});
});
});
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const passkeyAddBtn = document.getElementById('passkey-add-btn');
if (passkeyAddBtn) {
passkeyAddBtn.addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
const options = await beginResp.json();
const publicKey = options.publicKey;
publicKey.challenge = b64urlToBuf(publicKey.challenge);
publicKey.user.id = b64urlToBuf(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
}
const cred = await navigator.credentials.create({ publicKey });
const body = {
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
attestationObject: bufToB64url(cred.response.attestationObject),
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
},
};
const finishResp = await fetch('/webmail/account/passkey/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
window.location.reload();
} catch (e) {
errEl.textContent = e.message || 'Passkey registration failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,45 @@
{{define "webmail_login.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">Webmail</h4>
<p class="text-muted">Manage your mailbox account</p>
</div>
<div class="card">
<div class="card-body p-4">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/webmail/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
<div class="form-text">This is your mailbox account password — not an app password.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,113 @@
{{define "webmail_login_mfa.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify it's you - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">Verify it's you</h4>
<p class="text-muted">One more step to finish signing in</p>
</div>
<div class="card">
<div class="card-body p-4">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<div id="passkey-error" class="alert alert-danger d-none"></div>
{{if .has_passkeys}}
<div class="d-grid mb-3">
<button type="button" class="btn btn-outline-primary" id="passkey-btn">
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
</button>
</div>
{{if .totp_enabled}}<div class="text-center text-muted mb-3">or</div>{{end}}
{{end}}
{{if .totp_enabled}}
<form method="POST" action="/webmail/login/mfa">
<div class="mb-3">
<label for="code" class="form-label">6-digit authenticator code</label>
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-shield-check me-1"></i>Verify</button>
</div>
</form>
{{end}}
</div>
</div>
</div>
<script>
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const passkeyBtn = document.getElementById('passkey-btn');
if (passkeyBtn) {
passkeyBtn.addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const beginResp = await fetch('/webmail/login/passkey/begin');
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey login');
const options = await beginResp.json();
const publicKey = options.publicKey;
publicKey.challenge = b64urlToBuf(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
}
const assertion = await navigator.credentials.get({ publicKey });
const body = {
id: assertion.id,
rawId: bufToB64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufToB64url(assertion.response.authenticatorData),
clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
signature: bufToB64url(assertion.response.signature),
userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null,
},
};
const finishResp = await fetch('/webmail/login/passkey/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Passkey verification failed');
window.location.href = '/webmail/';
} catch (e) {
errEl.textContent = e.message || 'Passkey login failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,45 @@
{{define "webmail_totp_setup.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up authenticator app - Webmail</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Scan with your authenticator app</h5></div>
<div class="card-body text-center">
{{if .qr_data_uri}}
<img src="{{.qr_data_uri}}" alt="TOTP QR code" class="img-fluid mb-3" style="max-width: 256px; background: white; padding: 8px; border-radius: 8px;">
{{end}}
<p class="text-muted">Can't scan? Enter this key manually:</p>
<code class="d-block mb-4" style="word-break: break-all;">{{.secret}}</code>
<form method="POST" action="/webmail/account/totp/confirm" class="text-start">
<div class="mb-3">
<label for="code" class="form-label">Enter the 6-digit code from your app to confirm</label>
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
</div>
<div class="d-flex justify-content-between">
<a href="/webmail/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}