first commit

This commit is contained in:
2026-08-12 12:56:22 +01:00
commit ff96be2708
153 changed files with 27779 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
{{define "title"}}Account Settings{{end}}
{{define "page_title"}}Account Settings{{end}}
{{define "content"}}
<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-person-circle me-2"></i>Profile</h5></div>
<div class="card-body">
<p><strong>Username:</strong> {{.user.Username}}</p>
<hr>
<h6>Change password</h6>
<form method="POST" action="/pymta-manager/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 minlength="10">
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update password</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-phone me-2"></i>Authenticator App (TOTP)</h5></div>
<div class="card-body">
{{if .user.TOTPEnabled}}
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
<form method="POST" action="/pymta-manager/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. Add an authenticator app (Google Authenticator, 1Password, etc.) as an optional second factor.</p>
<form method="POST" action="/pymta-manager/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}}
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-fingerprint me-2"></i>Passkeys / Security Keys</h5></div>
<div class="card-body">
{{if .passkeys}}
<ul class="list-group mb-3">
{{range .passkeys}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="bi bi-key me-2"></i>{{.Name}} <small class="text-muted">added {{strftime "%Y-%m-%d" .CreatedAt}}</small></span>
<form method="POST" action="/pymta-manager/account/passkey/{{.ID}}/remove">
<button type="submit" class="btn btn-sm btn-outline-danger" data-confirm="Remove this passkey?">Remove</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted">No passkeys registered yet.</p>
{{end}}
<div id="passkey-error" class="alert alert-danger d-none"></div>
<button type="button" class="btn btn-outline-primary btn-sm" id="add-passkey-btn"><i class="bi bi-plus-circle me-1"></i>Add a passkey</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<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(/=+$/, '');
}
document.getElementById('add-passkey-btn').addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const name = prompt('Name this passkey (e.g. "YubiKey", "MacBook Touch ID"):', 'Passkey') || 'Passkey';
const beginResp = await fetch('/pymta-manager/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 credential = await navigator.credentials.create({ publicKey });
const body = {
id: credential.id,
rawId: bufToB64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufToB64url(credential.response.attestationObject),
clientDataJSON: bufToB64url(credential.response.clientDataJSON),
},
};
const finishResp = await fetch('/pymta-manager/account/passkey/finish?name=' + encodeURIComponent(name), {
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');
showToast('Passkey added', 'success');
setTimeout(() => location.reload(), 800);
} catch (e) {
errEl.textContent = e.message || 'Adding the passkey failed';
errEl.classList.remove('d-none');
}
});
</script>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "title"}}Add Admin{{end}}
{{define "page_title"}}Add Admin{{end}}
{{define "content"}}
<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-person-plus me-2"></i>Add a new admin</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Initial password</label>
<input type="password" class="form-control" id="password" name="password" required minlength="10">
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol. They'll be asked to change it on first login.</div>
</div>
{{if .can_grant_global}}
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_global_admin" name="is_global_admin">
<label class="form-check-label" for="is_global_admin"><strong>Global admin</strong></label>
<div class="form-text">Full access to every domain, sender, and setting — same as your own account. Leave unchecked to scope this admin to specific domains below.</div>
</div>
</div>
{{end}}
<div id="domain-picker" class="mb-4">
<label class="form-label">Domains this admin can manage</label>
{{if .domains}}
<div class="border rounded p-3" style="max-height: 240px; overflow-y: auto;">
{{range .domains}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="domain_ids" value="{{.ID}}" id="dom-{{.ID}}">
<label class="form-check-label" for="dom-{{.ID}}">{{.DomainName}}</label>
</div>
{{end}}
</div>
{{else}}
<p class="text-muted">You don't manage any domains yet to delegate.</p>
{{end}}
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/admins" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Create Admin</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
{{if .can_grant_global}}
<script>
document.getElementById('is_global_admin').addEventListener('change', function(e) {
document.getElementById('domain-picker').style.display = e.target.checked ? 'none' : '';
});
</script>
{{end}}
{{end}}
+46
View File
@@ -0,0 +1,46 @@
{{define "title"}}Add Domain - Email Server Management{{end}}
{{define "page_title"}}Add New Domain{{end}}
{{define "content"}}
<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-plus-circle me-2"></i>Add New Domain</h5></div>
<div class="card-body">
<form method="post">
<div class="mb-3">
<label for="domain_name" class="form-label"><i class="bi bi-globe me-1"></i>Domain Name</label>
<input type="text" class="form-control" id="domain_name" name="domain_name" placeholder="example.com" required
pattern="^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.?[a-zA-Z]{2,}$">
<div class="form-text">Enter the domain name that will be used for sending emails (e.g., example.com)</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>What happens next?</h6>
<ul class="mb-0">
<li>Domain will be added to the system</li>
<li>DKIM key pair will be automatically generated</li>
<li>You'll need to configure DNS records</li>
<li>Add users or whitelist IPs for authentication</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/domains" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Domains</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.getElementById('domain_name').addEventListener('input', function(e) {
let value = e.target.value.toLowerCase();
value = value.replace(/^https?:\/\//, '');
value = value.replace(/\/$/, '');
e.target.value = value;
});
</script>
{{end}}
+99
View File
@@ -0,0 +1,99 @@
{{define "title"}}Add IP Address - 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"><h6 class="mb-0"><i class="bi bi-geo-alt me-2"></i>Your Current IP</h6></div>
<div class="card-body text-center">
<div class="fw-bold font-monospace fs-5 mb-2" id="current-ip"><span class="spinner-border spinner-border-sm me-2"></span>Detecting...</div>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="useCurrentIP()"><i class="bi bi-arrow-up me-1"></i>Use This IP</button>
</div>
</div>
</div>
</div>
<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-shield-plus me-2"></i>Add IP Address to Whitelist</h4></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text" class="form-control font-monospace" id="ip_address" name="ip_address" required
pattern="^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
placeholder="192.168.1.100" value="{{.prefill_ip}}">
<div class="form-text">IPv4 address that will be allowed to send emails without authentication</div>
</div>
<div class="mb-4">
<label for="domain_id" class="form-label">Authorized Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
<div class="form-text">This IP will only be able to send emails for the selected domain</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Security Note</h6>
<ul class="mb-0">
<li>Only whitelist trusted IP addresses</li>
<li>This IP can send emails without username/password authentication</li>
<li>The IP is restricted to the selected domain only</li>
<li>Use static IP addresses for reliable access</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/ips" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to IP List</a>
<button type="submit" class="btn btn-success"><i class="bi bi-shield-plus me-2"></i>Add to Whitelist</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
async function detectCurrentIP() {
try {
const response = await fetch('https://ifconfig.me/all.json');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.ip_addr}</span>`;
} catch (er) {
try {
const response = await fetch('https://httpbin.org/ip');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.origin}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML = '<span class="text-muted">Unable to detect</span>';
}
}
}
function useCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
document.getElementById('ip_address').value = ip;
document.getElementById('domain_id').focus();
} else {
showToast('Unable to detect current IP address', 'danger');
}
}
document.getElementById('ip_address').addEventListener('input', function(e) {
const ip = e.target.value;
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ip && !ipPattern.test(ip)) { e.target.setCustomValidity('Please enter a valid IPv4 address'); } else { e.target.setCustomValidity(''); }
});
detectCurrentIP();
</script>
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "title"}}Add Sender - 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-person-plus me-2"></i>Add New Sender</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">The sender always belongs to the domain selected here — this can't be typed as free text, so it can't drift from the domain it's assigned to.</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="6">
<div class="form-text">Minimum 6 characters</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as_domain" name="can_send_as_domain">
<label class="form-check-label" for="can_send_as_domain"><strong>Domain Sender</strong></label>
<div class="form-text">If checked, sender can send emails as any address in their domain. Otherwise, sender can only send as their own email address.</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content">
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs. Otherwise, only headers and subject are stored.</div>
</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>Permission Levels</h6>
<ul class="mb-0">
<li><strong>Regular Sender:</strong> Can only send emails from their own email address</li>
<li><strong>Domain Sender:</strong> Can send emails from any address in their domain</li>
</ul>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/senders" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Senders</a>
<button type="submit" class="btn btn-success"><i class="bi bi-person-plus me-2"></i>Add Sender</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.getElementById('can_send_as_domain').addEventListener('change', function(e) {
const isChecked = e.target.checked;
const domainSelect = document.getElementById('domain_id');
const selectedDomain = domainSelect.options[domainSelect.selectedIndex]?.text || 'domain.com';
const helpText = e.target.closest('.form-check').querySelector('.form-text');
if (isChecked) {
helpText.innerHTML = `User can send as any address in ${selectedDomain}`;
} else {
helpText.innerHTML = 'User can only send as their own email address.';
}
});
document.getElementById('domain_id').addEventListener('change', function(e) {
const checkbox = document.getElementById('can_send_as_domain');
if (checkbox.checked) { checkbox.dispatchEvent(new Event('change')); }
});
</script>
{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "title"}}Manage Admins{{end}}
{{define "page_title"}}Manage Admins{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people-fill me-2"></i>Admins</h2>
<a href="/pymta-manager/admins/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Admin</a>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
Delegated admins can only see and manage the domains you assign them. They can also delegate further, but only for domains within their own assignment.
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Admins You Manage</h5></div>
<div class="card-body p-0">
{{if .rows}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .rows}}
{{$u := .user}}
<tr>
<td class="fw-bold">{{$u.Username}}</td>
<td>
{{if $u.IsGlobalAdmin}}<span class="badge bg-danger"><i class="bi bi-shield-fill-check me-1"></i>Global Admin</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-shield me-1"></i>Scoped Admin</span>{{end}}
</td>
<td>
{{if $u.IsGlobalAdmin}}<span class="text-muted">All domains</span>
{{else if .domain_names}}{{range .domain_names}}<span class="badge bg-info text-dark me-1">{{.}}</span>{{end}}
{{else}}<span class="text-muted">None assigned</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $u.CreatedAt}}</small></td>
<td>
<div class="btn-group btn-group-sm" role="group">
{{if not $u.IsGlobalAdmin}}
<a href="/pymta-manager/admins/{{$u.ID}}/edit" class="btn btn-outline-primary" title="Edit Domain Access"><i class="bi bi-pencil"></i></a>
{{end}}
<form method="post" action="/pymta-manager/admins/{{$u.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" data-confirm="Permanently remove admin {{$u.Username}}? This cannot be undone." title="Remove Admin"><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-people text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No delegated admins yet</h4>
<p class="text-muted">Add an admin and assign them the domains they should manage</p>
<a href="/pymta-manager/admins/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Your First Admin</a>
</div>
{{end}}
</div>
</div>
{{end}}
+217
View File
@@ -0,0 +1,217 @@
<!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>{{block "title" .}}Email Server Management{{end}}</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>
:root { --sidebar-width: 280px; }
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; }
.navbar-brand { color: #fff !important; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
.btn-outline-light:hover { background-color: #495057; }
.alert-success { background-color: #0f5132; border-color: #146c43; color: #75b798; }
.alert-danger { background-color: #58151c; border-color: #842029; color: #ea868f; }
.alert-warning { background-color: #664d03; border-color: #997404; color: #ffda6a; }
.alert-info { background-color: #055160; border-color: #087990; color: #6edff6; }
.form-control:focus { border-color: #0d6efd; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); }
.form-select:focus { border-color: #0d6efd; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); }
.text-muted { color: #adb5bd !important; }
.border-success { border-color: #198754 !important; }
.border-danger { border-color: #dc3545 !important; }
.text-success { color: #75b798 !important; }
.text-danger { color: #ea868f !important; }
.text-warning { color: #ffda6a !important; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #2d2d2d; }
::-webkit-scrollbar-thumb { background: #495057; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #6c757d; }
</style>
<link href="/pymta-manager/static/css/smtp-management.css" rel="stylesheet">
<style>
.tooltip-inner { color: #fff !important; background-color: #222 !important; font-size: 1rem; text-align: left; }
.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,
.bs-tooltip-top .tooltip-arrow::before { border-top-color: #222 !important; }
</style>
{{block "extra_css" .}}{{end}}
</head>
<body>
<div class="main-container">
{{template "sidebar_email.html" .}}
<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">
<i class="bi bi-envelope-fill me-2"></i>
{{block "page_title" .}}Email Server Management{{end}}
</span>
<div class="navbar-nav ms-auto">
<span class="navbar-text">
<i class="bi bi-clock-fill me-1"></i>
<span id="current-time"></span>
</span>
</div>
</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>
<main>
{{block "content" .}}{{end}}
</main>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-labelledby="confirmationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmationModalLabel">
<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-primary" 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>
function updateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString();
const dateString = now.toLocaleDateString();
document.getElementById('current-time').textContent = `${dateString} ${timeString}`;
}
setInterval(updateTime, 1000);
updateTime();
// 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.
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() {
const toastElements = document.querySelectorAll('.toast');
toastElements.forEach(function(toastElement) {
const toast = new bootstrap.Toast(toastElement);
toast.show();
armToastAutoDismiss(toastElement, toast);
});
});
function showToast(message, type = 'info') {
const toastContainer = document.querySelector('.toast-container');
const toastId = 'toast-' + Date.now();
const iconMap = { 'danger': 'exclamation-triangle', 'success': 'check-circle', 'warning': 'exclamation-triangle', 'info': 'info-circle' };
const toastHtml = `
<div id="${toastId}" class="toast align-items-center text-bg-${type} 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-${iconMap[type] || 'info-circle'} 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>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastEl = document.getElementById(toastId);
const newToast = new bootstrap.Toast(toastEl);
newToast.show();
armToastAutoDismiss(toastEl, newToast);
toastEl.addEventListener('hidden.bs.toast', function() { this.remove(); });
}
function showConfirmation(message, title = 'Confirm Action', confirmButtonText = 'Confirm', confirmButtonClass = 'btn-primary') {
return new Promise((resolve) => {
const modal = document.getElementById('confirmationModal');
const modalTitle = document.getElementById('confirmationModalLabel');
const modalBody = document.getElementById('confirmationModalBody');
const confirmButton = document.getElementById('confirmationModalConfirm');
modalTitle.innerHTML = `<i class="bi bi-question-circle me-2"></i>${title}`;
modalBody.textContent = message;
confirmButton.textContent = confirmButtonText;
confirmButton.className = `btn ${confirmButtonClass}`;
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() {
const deleteButtons = document.querySelectorAll('[data-confirm]');
deleteButtons.forEach(function(button) {
button.addEventListener('click', async function(e) {
e.preventDefault();
const confirmMessage = this.getAttribute('data-confirm');
const confirmed = await showConfirmation(confirmMessage, 'Confirm Action', 'Confirm', 'btn-danger');
if (confirmed) {
const form = this.closest('form');
if (form) { form.submit(); } else if (this.href) { window.location.href = this.href; }
}
});
});
});
</script>
<script src="/pymta-manager/static/js/smtp-management.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
});
});
</script>
{{block "extra_js" .}}{{end}}
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
{{define "title"}}Dashboard - Email Server Management{{end}}
{{define "page_title"}}Dashboard{{end}}
{{define "content"}}
<div class="row">
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/domains" class="text-decoration-none">
<div class="card border-primary">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-primary mb-1"><i class="bi bi-globe me-2"></i>Domains</h5>
<h3 class="mb-0">{{.domain_count}}</h3>
<small class="text-muted">Active domains</small>
</div>
<div class="fs-2 text-primary opacity-50"><i class="bi bi-globe"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/senders" class="text-decoration-none">
<div class="card border-success">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-success mb-1"><i class="bi bi-people me-2"></i>Senders</h5>
<h3 class="mb-0">{{.sender_count}}</h3>
<small class="text-muted">Authenticated senders</small>
</div>
<div class="fs-2 text-success opacity-50"><i class="bi bi-people"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/dkim" class="text-decoration-none">
<div class="card border-warning">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-warning mb-1"><i class="bi bi-shield-check me-2"></i>DKIM Keys</h5>
<h3 class="mb-0">{{.dkim_count}}</h3>
<small class="text-muted">Active DKIM keys</small>
</div>
<div class="fs-2 text-warning opacity-50"><i class="bi bi-shield-check"></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">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title text-info mb-1"><i class="bi bi-activity me-2"></i>Status</h5>
<h6 class="{{if eq .health.Status "healthy"}}text-success{{else}}text-warning{{end}} mb-0">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
{{title .health.Status}}
</h6>
<small class="text-muted">
{{if and (eq .health.Services.smtp_server "running") (eq .health.Services.database "ok")}}
All services running
{{else}}
{{if eq .health.Services.smtp_server "stopped"}}SMTP Server stopped{{end}}
{{if eq .health.Services.database "error"}}Database error{{end}}
{{end}}
</small>
</div>
<div class="fs-2 text-info opacity-50"><i class="bi bi-activity"></i></div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-envelope me-2"></i>Recent Email Activity</h5>
<a href="/pymta-manager/logs?type=emails" class="btn btn-outline-light btn-sm">View All</a>
</div>
<div class="card-body p-0">
{{if .recent_emails}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Time</th><th>From</th><th>Recipients</th><th>Status</th><th>DKIM</th></tr></thead>
<tbody>
{{range .recent_emails}}
<tr>
<td><small class="text-muted">{{formatDatetime .CreatedAt}}</small></td>
<td><span class="text-truncate d-inline-block" style="max-width: 150px;" title="{{.MailFrom}}">{{.MailFrom}}</span></td>
<td>
<div style="max-width: 200px; font-size: 0.85rem;">
{{if .ToAddress}}<div class="text-truncate"><span class="text-info fw-bold" style="font-size: 0.75rem;">To:</span> {{.ToAddress}}</div>{{end}}
{{if .CcAddresses}}<div class="text-truncate"><span class="text-warning fw-bold" style="font-size: 0.75rem;">CC:</span> {{.CcAddresses}}</div>{{end}}
{{if .BccAddresses}}<div class="text-truncate"><span class="text-secondary fw-bold" style="font-size: 0.75rem;">BCC:</span> {{.BccAddresses}}</div>{{end}}
</div>
</td>
<td>
{{if eq .Status "relayed"}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Sent</span>
{{else if eq .Status "partial"}}
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Partial Fail</span>
{{else}}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
{{end}}
</td>
<td>
{{if .DKIMSigned}}<span class="text-success"><i class="bi bi-shield-check" title="DKIM Signed"></i></span>
{{else}}<span class="text-muted"><i class="bi bi-shield-x" title="Not DKIM Signed"></i></span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-4"><i class="bi bi-envelope text-muted fs-1"></i><p class="text-muted mt-2">No email activity yet</p></div>
{{end}}
</div>
</div>
</div>
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Recent Auth Activity</h5>
<a href="/pymta-manager/logs?type=auth" class="btn btn-outline-light btn-sm">View All</a>
</div>
<div class="card-body p-0">
{{if .recent_auths}}
<div class="list-group list-group-flush">
{{range .recent_auths}}
<div class="list-group-item list-group-item-dark d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">
{{if .Success}}<i class="bi bi-check-circle text-success me-1"></i>{{else}}<i class="bi bi-x-circle text-danger me-1"></i>{{end}}
{{title .AuthType}}
</div>
<small class="text-muted">{{.Identifier}}</small><br>
<small class="text-muted">{{formatDatetime .CreatedAt}}</small>
</div>
<small class="text-muted">{{.IPAddress}}</small>
</div>
{{end}}
</div>
{{else}}
<div class="text-center py-4"><i class="bi bi-shield-lock text-muted fs-1"></i><p class="text-muted mt-2">No authentication activity yet</p></div>
{{end}}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-lightning me-2"></i>Quick Actions</h5></div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/domains/add" class="btn btn-outline-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/senders/add" class="btn btn-outline-success"><i class="bi bi-person-plus me-2"></i>Add Sender</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/ips/add" class="btn btn-outline-warning"><i class="bi bi-shield-plus me-2"></i>Whitelist IP</a></div></div>
<div class="col-md-3 mb-3"><div class="d-grid"><a href="/pymta-manager/settings" class="btn btn-outline-info"><i class="bi bi-gear me-2"></i>Settings</a></div></div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
setTimeout(function() { location.reload(); }, 30000);
</script>
{{end}}
+290
View File
@@ -0,0 +1,290 @@
{{define "title"}}DKIM Keys - Email Server{{end}}
{{define "extra_css"}}
<style>
.dns-record { font-family: 'Courier New', monospace; color: black; background-color: var(--bs-gray-100); border-radius: 0.375rem; padding: 0.75rem; border: 1px solid var(--bs-border-color); word-break: break-all; }
.status-indicator { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 0.5rem; }
.status-success { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-danger { background-color: #dc3545; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-check me-2"></i>DKIM Key Management</h2>
<div class="btn-group">
<button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#createDKIMModal"><i class="bi bi-plus-circle me-2"></i>Create DKIM</button>
<button class="btn btn-outline-info" data-action="check-all-dns"><i class="bi bi-arrow-clockwise me-2"></i>Check All DNS</button>
</div>
</div>
<div class="modal fade" id="createDKIMModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="createDKIMForm" method="post" action="/pymta-manager/dkim/create">
<div class="modal-header">
<h5 class="modal-title">Create New DKIM Key</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="dkimDomain" class="form-label">Domain</label>
<select class="form-select" id="dkimDomain" name="domain" required>
<option value="" disabled selected>Select domain</option>
{{range .dkim_data}}<option value="{{.domain.domain_name}}">{{.domain.domain_name}}</option>{{end}}
</select>
</div>
<div class="mb-3">
<label for="dkimSelector" class="form-label">Selector (optional)</label>
<input type="text" class="form-control" id="dkimSelector" name="selector" maxlength="32" placeholder="Leave blank for random selector">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
{{range .dkim_data}}
{{$domain := .domain}}{{$key := .dkim_key}}{{$slug := dotToDash $domain.domain_name}}
<div class="card mb-4" id="domain-{{$slug}}" data-is-active="{{$key.IsActive}}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{$slug}}">
<h5 class="mb-0">
<i class="bi bi-server me-2"></i>
{{$domain.domain_name}}
{{if $key.IsActive}}<span class="badge bg-success ms-2">Active</span>{{else}}<span class="badge bg-secondary ms-2">Inactive</span>{{end}}
</h5>
</div>
<div class="btn-group btn-group-sm me-2">
<button class="btn btn-outline-primary" data-action="check-dns" data-domain="{{$domain.domain_name}}" data-selector="{{$key.Selector}}" onclick="event.stopPropagation();">
<i class="bi bi-search me-1"></i>Check DNS
</button>
<div class="btn-group btn-group-sm" role="group">
<a href="/pymta-manager/dkim/{{$key.ID}}/edit" class="btn btn-outline-info" onclick="event.stopPropagation();"><i class="bi bi-pencil me-1"></i>Edit</a>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/toggle" class="d-inline">
{{if $key.IsActive}}
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();" title="Disable DKIM"><i class="bi bi-pause-circle me-1"></i>Disable</button>
{{else}}
<button type="submit" class="btn btn-outline-success" onclick="event.stopPropagation();" title="Enable DKIM"><i class="bi bi-play-circle me-1"></i>Enable</button>
{{end}}
</form>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" onclick="event.stopPropagation();" data-confirm="Permanently remove the DKIM key for {{$domain.domain_name}}? You will lose the ability to sign emails until you regenerate a new key.">
<i class="bi bi-trash me-1"></i>Remove
</button>
</form>
</div>
<form method="post" action="/pymta-manager/dkim/{{$domain.id}}/regenerate" class="d-inline" onsubmit="event.stopPropagation();">
<button type="submit" class="btn btn-outline-warning" onclick="event.stopPropagation();"><i class="bi bi-arrow-clockwise me-1"></i>Regenerate</button>
</form>
</div>
<div class="card-header-clickable" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapse-{{$slug}}">
<i class="bi bi-chevron-down" id="chevron-{{$slug}}"></i>
</div>
</div>
</div>
<div class="collapse" id="collapse-{{$slug}}">
<div class="card-body">
<div class="row">
<div class="col-lg-6 mb-3">
<h6>
<i class="bi bi-key me-2"></i>DKIM DNS Record
<span class="dns-status" id="dkim-status-{{$slug}}"><span class="status-indicator status-warning"></span><small class="text-muted">Active (DNS not checked)</small></span>
</h6>
<div class="mb-2"><strong>Name:</strong><div class="dns-record">{{.dns_record.name}}</div></div>
<div class="mb-2"><strong>Type:</strong> TXT</div>
<div class="mb-2"><strong>Value:</strong><div class="dns-record">{{.dns_record.value}}</div></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard('{{.dns_record.value}}')"><i class="bi bi-clipboard me-1"></i>Copy Value</button>
</div>
<div class="col-lg-6 mb-3">
<h6>
<i class="bi bi-shield-lock me-2"></i>SPF DNS Record
<span class="dns-status" id="spf-status-{{$slug}}"><span class="status-indicator status-warning"></span><small class="text-muted">Not checked</small></span>
</h6>
<div class="mb-2"><strong>Name:</strong><div class="dns-record">{{$domain.domain_name}}</div></div>
<div class="mb-2"><strong>Type:</strong> TXT</div>
{{if .existing_spf}}<div class="mb-2"><strong>Current SPF:</strong><div class="dns-record">{{.existing_spf}}</div></div>{{end}}
<div class="mb-2"><strong>Recommended SPF:</strong><div class="dns-record">{{.recommended_spf}}</div></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard('{{.recommended_spf}}')"><i class="bi bi-clipboard me-1"></i>Copy SPF</button>
</div>
</div>
<div class="row">
<div class="col-12">
<h6><i class="bi bi-info-circle me-2"></i>Key Information</h6>
<div class="row">
<div class="col-md-3"><strong>Selector:</strong><br><code>{{$key.Selector}}</code></div>
<div class="col-md-3"><strong>Created:</strong><br>{{strftime "%Y-%m-%d %H:%M" $key.CreatedAt}}</div>
<div class="col-md-3"><strong>Server IP:</strong><br><code>{{.public_ip}}</code></div>
<div class="col-md-3"><strong>Status:</strong><br>{{if $key.IsActive}}<span class="text-success">Active</span>{{else}}<span class="text-secondary">Inactive</span>{{end}}</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{if .old_dkim_data}}
<div class="card mb-4">
<div class="card-header"><h4 class="mb-0"><i class="bi bi-archive me-2"></i>Old DKIM Keys <span class="badge bg-secondary ms-2">{{len .old_dkim_data}}</span></h4></div>
<div class="card-body">
<p class="text-muted mb-3">These keys have been replaced or disabled. They are kept for reference and can be permanently removed.</p>
{{range .old_dkim_data}}
{{$domain := .domain}}{{$key := .dkim_key}}
<div class="card mb-3 border-secondary">
<div class="card-header bg-dark">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-0"><i class="bi bi-server me-2"></i>{{$domain.domain_name}}<span class="badge bg-secondary ms-2">{{.status_text}}</span></h6>
<small class="text-muted">Selector: <code>{{$key.Selector}}</code> | Created: {{strftime "%Y-%m-%d %H:%M" $key.CreatedAt}}</small>
</div>
<div class="btn-group btn-group-sm">
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/toggle" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm"><i class="bi bi-play-circle me-1"></i>Reactivate</button>
</form>
<form method="post" action="/pymta-manager/dkim/{{$key.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Permanently remove this old DKIM key? This action cannot be undone."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if not .dkim_data}}
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-shield-x text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No DKIM Keys Found</h4>
<p class="text-muted">Add domains first to automatically generate DKIM keys</p>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a>
</div>
</div>
{{end}}
</div>
<div class="modal fade" id="dnsResultModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">DNS Check Results</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="dnsResults"></div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button></div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() { showToast('Copied to clipboard!', 'success'); }, function(err) { showToast('Failed to copy: ' + err, 'danger'); });
}
async function checkDomainDNS(domain, selector) {
const dkimStatus = document.getElementById(`dkim-status-${domain.replace('.', '-')}`);
const spfStatus = document.getElementById(`spf-status-${domain.replace('.', '-')}`);
dkimStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
spfStatus.innerHTML = '<span class="status-indicator status-warning"></span><small class="text-muted">Checking...</small>';
try {
const dkimResponse = await fetch('/pymta-manager/dkim/check_dns', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({domain, selector}) });
const dkimResult = await dkimResponse.json();
const spfResponse = await fetch('/pymta-manager/dkim/check_spf', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({domain}) });
const spfResult = await spfResponse.json();
const domainCard = document.getElementById(`domain-${domain.replace('.', '-')}`);
const isActive = domainCard && domainCard.dataset.isActive === 'true';
if (isActive) {
dkimStatus.innerHTML = dkimResult.success
? '<span class="status-indicator status-success"></span><small class="text-success">Active & Configured</small>'
: '<span class="status-indicator" style="background-color: #fd7e14;"></span><small class="text-warning">Active but DNS not found</small>';
} else {
dkimStatus.innerHTML = '<span class="status-indicator" style="background-color: #6c757d;"></span><small class="text-muted">Disabled</small>';
}
spfStatus.innerHTML = spfResult.success
? '<span class="status-indicator status-success"></span><small class="text-success">Found</small>'
: '<span class="status-indicator status-danger"></span><small class="text-danger">Not found</small>';
showDNSResults(domain, dkimResult, spfResult);
} catch (error) {
dkimStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
spfStatus.innerHTML = '<span class="status-indicator status-danger"></span><small class="text-danger">Error</small>';
}
}
function showDNSResults(domain, dkimResult, spfResult) {
const resultsHtml = `
<h6>DNS Check Results for ${domain}</h6>
<div class="mb-3"><h6 class="text-primary">DKIM Record</h6>
<div class="alert ${dkimResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${dkimResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${dkimResult.message}
</div>
</div>
<div class="mb-3"><h6 class="text-primary">SPF Record</h6>
<div class="alert ${spfResult.success ? 'alert-success' : 'alert-danger'}">
<strong>Status:</strong> ${spfResult.success ? 'Found' : 'Not Found'}<br>
<strong>Message:</strong> ${spfResult.message}
</div>
</div>`;
document.getElementById('dnsResults').innerHTML = resultsHtml;
new bootstrap.Modal(document.getElementById('dnsResultModal')).show();
}
async function checkAllDNS() {
const cards = document.querySelectorAll('[data-action="check-dns"]');
for (const btn of cards) {
await checkDomainDNS(btn.dataset.domain, btn.dataset.selector);
}
}
document.addEventListener('DOMContentLoaded', function() {
const checkAllBtn = document.querySelector('[data-action="check-all-dns"]');
if (checkAllBtn) { checkAllBtn.addEventListener('click', checkAllDNS); }
document.querySelectorAll('[data-action="check-dns"]').forEach(button => {
button.addEventListener('click', function(event) {
event.stopPropagation();
checkDomainDNS(this.dataset.domain, this.dataset.selector);
});
});
document.querySelectorAll('.card-header-clickable[data-bs-toggle="collapse"]').forEach(function(element) {
element.addEventListener('click', function() {
const targetId = this.getAttribute('data-bs-target');
const chevron = document.querySelector(targetId.replace('#collapse-', '#chevron-'));
if (chevron) {
setTimeout(() => {
const collapseElement = document.querySelector(targetId);
chevron.className = (collapseElement && collapseElement.classList.contains('show')) ? 'bi bi-chevron-up' : 'bi bi-chevron-down';
}, 100);
}
});
});
document.querySelectorAll('form[action*="toggle"]').forEach(form => {
if (!form.action.includes('/dkim/')) return;
form.addEventListener('submit', async function(event) {
event.preventDefault();
const response = await fetch(this.action, { method: 'POST', body: new FormData(this), headers: {'X-Requested-With': 'XMLHttpRequest'} });
if (response.ok) {
const result = await response.json();
if (result.success) { showToast(result.message, 'success'); setTimeout(() => location.reload(), 600); }
else { showToast(result.message, 'danger'); }
}
});
});
document.getElementById('createDKIMForm').addEventListener('submit', async function(event) {
event.preventDefault();
const response = await fetch(this.action, { method: 'POST', body: new FormData(this) });
const result = await response.json();
if (result.success) { showToast(result.message, 'success'); setTimeout(() => location.reload(), 600); }
else { showToast(result.message, 'danger'); }
});
});
</script>
{{end}}
+155
View File
@@ -0,0 +1,155 @@
{{define "title"}}Domains - Email Server Management{{end}}
{{define "page_title"}}Domain Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-globe me-2"></i>Domains</h2>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Domain</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Domains</h5></div>
<div class="card-body p-0">
{{if .rows}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Domain Name</th><th>Status</th><th>Ownership</th><th>Created</th><th>Senders</th><th>DKIM</th><th>Actions</th></tr></thead>
<tbody>
{{range .rows}}
{{$domain := .domain}}
<tr>
<td><div class="fw-bold">{{$domain.DomainName}}</div></td>
<td>
{{if $domain.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>
{{if $domain.IsVerified}}
<span class="badge bg-success" data-bs-toggle="tooltip" title="DNS ownership verified — this domain can send mail"><i class="bi bi-patch-check-fill me-1"></i>Verified</span>
{{else}}
<span class="badge bg-warning text-dark" data-bs-toggle="tooltip" title="Not yet verified — sending is blocked until the DNS TXT record is confirmed"><i class="bi bi-exclamation-triangle me-1"></i>Unverified</span>
<button type="button" class="btn btn-outline-warning btn-sm ms-1"
data-action="show-verify"
data-domain-id="{{$domain.ID}}"
data-domain-name="{{$domain.DomainName}}"
data-record-name="_pymta-verify.{{$domain.DomainName}}"
data-record-value="pymta-verify={{$domain.VerificationToken}}">
Verify
</button>
{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $domain.CreatedAt}}</small></td>
<td><span class="badge bg-info">{{.sender_count}} senders</span></td>
<td>
{{if .has_active_dkim}}
<span class="status-indicator status-warning"></span>
<i class="bi bi-shield-check" title="DKIM Active (DNS not checked)"></i>
{{else if .has_any_dkim}}
<span class="text-secondary"><i class="bi bi-shield" title="DKIM Disabled"></i></span>
{{else}}
<span class="text-danger"><i class="bi bi-shield-exclamation" title="No DKIM Key"></i></span>
{{end}}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/pymta-manager/domains/{{$domain.ID}}/edit" class="btn btn-outline-primary" title="Edit Domain"><i class="bi bi-pencil"></i></a>
<form method="post" action="/pymta-manager/domains/{{$domain.ID}}/toggle" class="d-inline">
{{if $domain.IsActive}}
<button type="submit" class="btn btn-outline-warning" data-confirm="Are you sure you want to disable domain {{$domain.DomainName}}?" title="Disable Domain"><i class="bi bi-pause-circle"></i></button>
{{else}}
<button type="submit" class="btn btn-outline-success" data-confirm="Are you sure you want to enable domain {{$domain.DomainName}}?" title="Enable Domain"><i class="bi bi-play-circle"></i></button>
{{end}}
</form>
<form method="post" action="/pymta-manager/domains/{{$domain.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger" data-confirm="WARNING: This will permanently delete domain {{$domain.DomainName}} and ALL associated data. This action cannot be undone. Continue?" title="Permanently Remove Domain"><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-globe text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No domains configured</h4>
<p class="text-muted">Get started by adding your first domain</p>
<a href="/pymta-manager/domains/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add Your First Domain</a>
</div>
{{end}}
</div>
</div>
<div class="modal fade" id="verifyDomainModal" 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-patch-check me-2"></i>Verify domain ownership</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Add this DNS TXT record for <strong id="verifyDomainName"></strong>, then check:</p>
<div class="mb-2"><strong>Name:</strong><div class="dns-record" id="verifyRecordName" style="font-family: monospace; background: var(--bs-gray-100); color: #111; border-radius: 0.375rem; padding: 0.6rem; word-break: break-all;"></div></div>
<div class="mb-3"><strong>Value:</strong><div class="dns-record" id="verifyRecordValue" style="font-family: monospace; background: var(--bs-gray-100); color: #111; border-radius: 0.375rem; padding: 0.6rem; word-break: break-all;"></div></div>
<div id="verifyResult"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="verifyCheckNowBtn"><i class="bi bi-arrow-clockwise me-1"></i>Check Now</button>
</div>
</div>
</div>
</div>
<style>
.status-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 0.5rem; }
.status-success { background-color: #28a745; }
.status-warning { background-color: #ffc107; }
.status-danger { background-color: #dc3545; }
</style>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
let currentDomainID = null;
const modalEl = document.getElementById('verifyDomainModal');
const modal = new bootstrap.Modal(modalEl);
document.querySelectorAll('[data-action="show-verify"]').forEach(btn => {
btn.addEventListener('click', function() {
currentDomainID = this.dataset.domainId;
document.getElementById('verifyDomainName').textContent = this.dataset.domainName;
document.getElementById('verifyRecordName').textContent = this.dataset.recordName;
document.getElementById('verifyRecordValue').textContent = this.dataset.recordValue;
document.getElementById('verifyResult').innerHTML = '';
modal.show();
});
});
document.getElementById('verifyCheckNowBtn').addEventListener('click', async function() {
if (!currentDomainID) return;
const btn = this;
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Checking...';
try {
const response = await fetch(`/pymta-manager/domains/${currentDomainID}/verify_check`, { method: 'POST' });
const result = await response.json();
const resultEl = document.getElementById('verifyResult');
resultEl.innerHTML = `<div class="alert ${result.success ? 'alert-success' : 'alert-warning'} mb-0 mt-2">${result.message}</div>`;
if (result.success) {
showToast(result.message, 'success');
setTimeout(() => location.reload(), 1200);
}
} catch (e) {
showToast('DNS check failed', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = original;
}
});
});
</script>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "title"}}Edit Admin Access{{end}}
{{define "page_title"}}Edit Admin Access{{end}}
{{define "content"}}
<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-pencil-square me-2"></i>{{.target.Username}}'s domain access</h5></div>
<div class="card-body">
<form method="POST">
{{if .domains}}
<div class="border rounded p-3 mb-4" style="max-height: 300px; overflow-y: auto;">
{{range .domains}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="domain_ids" value="{{.ID}}" id="dom-{{.ID}}" {{if index $.assigned .ID}}checked{{end}}>
<label class="form-check-label" for="dom-{{.ID}}">{{.DomainName}}</label>
</div>
{{end}}
</div>
{{else}}
<p class="text-muted">You don't manage any domains to assign.</p>
{{end}}
<div class="d-flex justify-content-between">
<a href="/pymta-manager/admins" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-2"></i>Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "title"}}Edit DKIM Selector{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-pencil me-2"></i>Edit DKIM Selector</h4>
<a href="/pymta-manager/dkim" class="btn btn-light btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to DKIM Keys</a>
</div>
<div class="card-body">
<form method="POST" class="needs-validation" novalidate>
<div class="mb-3">
<label for="selector" class="form-label"><i class="bi bi-key me-1"></i>DKIM Selector</label>
<input type="text" class="form-control" id="selector" name="selector" value="{{.dkim_key.Selector}}" placeholder="default" pattern="^[a-zA-Z0-9_-]+$" required>
<div class="invalid-feedback">Please provide a valid selector (letters, numbers, hyphens, and underscores only).</div>
<div class="form-text"><i class="bi bi-info-circle me-1"></i>The selector is used in DNS records to identify this DKIM key (e.g., "selector._domainkey.{{.domain.DomainName}}")</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<h6><i class="bi bi-info-circle me-1"></i>Current Information</h6>
<p class="mb-1"><strong>Domain:</strong> {{.domain.DomainName}}</p>
<p class="mb-1"><strong>Current Selector:</strong> {{.dkim_key.Selector}}</p>
<p class="mb-1"><strong>Status:</strong> {{if .dkim_key.IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Inactive</span>{{end}}</p>
<p class="mb-0"><strong>Created:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .dkim_key.CreatedAt}}</p>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-1"></i>Important Note</h6>
<p class="mb-0">Changing the selector will require updating your DNS records to match the new selector name.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/dkim" class="btn btn-secondary"><i class="bi bi-x me-1"></i>Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Update Selector</button>
</div>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-info text-white"><h5 class="mb-0"><i class="bi bi-dns me-2"></i>DNS Record Information</h5></div>
<div class="card-body">
<div class="alert alert-light">
<h6>Current DNS Record</h6>
<p class="mb-2"><strong>Name:</strong> <code>{{.dkim_key.Selector}}._domainkey.{{.domain.DomainName}}</code></p>
<p class="mb-0"><strong>Type:</strong> TXT</p>
</div>
<p class="text-muted"><i class="bi bi-lightbulb me-1"></i><strong>Tip:</strong> After changing the selector, update your DNS provider to use the new record name. The value stays the same.</p>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) { event.preventDefault(); event.stopPropagation(); }
form.classList.add('was-validated');
}, false);
});
}, false);
})();
document.getElementById('selector').addEventListener('input', function(e) {
const selectorRegex = /^[a-zA-Z0-9_-]+$/;
if (e.target.value && !selectorRegex.test(e.target.value)) {
e.target.setCustomValidity('Selector must contain only letters, numbers, hyphens, and underscores');
} else {
e.target.setCustomValidity('');
}
});
</script>
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "title"}}Edit Domain{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-pencil me-2"></i>Edit Domain</h4>
<a href="/pymta-manager/domains" class="btn btn-light btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to Domains</a>
</div>
<div class="card-body">
<form method="POST" class="needs-validation" novalidate>
<div class="mb-3">
<label for="domain_name" class="form-label"><i class="bi bi-globe me-1"></i>Domain Name</label>
<input type="text" class="form-control" id="domain_name" name="domain_name" value="{{.domain.DomainName}}" placeholder="example.com"
pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" required>
<div class="invalid-feedback">Please provide a valid domain name.</div>
<div class="form-text"><i class="bi bi-info-circle me-1"></i>Enter a fully qualified domain name (e.g., example.com)</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<h6><i class="bi bi-info-circle me-1"></i>Current Status</h6>
<p class="mb-1"><strong>Status:</strong>
{{if .domain.IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Inactive</span>{{end}}
</p>
<p class="mb-0"><strong>Created:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .domain.CreatedAt}}</p>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-warning">
<h6><i class="bi bi-exclamation-triangle me-1"></i>Note</h6>
<p class="mb-0">Changing the domain name will affect all associated users, IP addresses, and DKIM keys. Make sure to update your DNS records accordingly.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/domains" class="btn btn-secondary"><i class="bi bi-x me-1"></i>Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Update Domain</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) { event.preventDefault(); event.stopPropagation(); }
form.classList.add('was-validated');
}, false);
});
}, false);
})();
document.getElementById('domain_name').addEventListener('input', function(e) {
const value = e.target.value.toLowerCase();
e.target.value = value;
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
if (value && !domainRegex.test(value)) { e.target.setCustomValidity('Invalid domain format'); } else { e.target.setCustomValidity(''); }
});
</script>
{{end}}
+63
View File
@@ -0,0 +1,63 @@
{{define "title"}}Edit IP Whitelist - 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-pencil-square me-2"></i>Edit IP Whitelist Entry</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="ip_address" class="form-label">IP Address</label>
<input type="text" class="form-control" id="ip_address" name="ip_address" value="{{.ip_record.IPAddress}}" placeholder="e.g., 192.168.1.1" required>
<div class="form-text">Enter a single IPv4 address</div>
</div>
<div class="mb-3">
<label for="domain_id" class="form-label">Domain</label>
<select class="form-select" id="domain_id" name="domain_id" required>
<option value="">Select a domain</option>
{{range .domains}}<option value="{{.ID}}" {{if eq .ID $.ip_record.DomainID}}selected{{end}}>{{.DomainName}}</option>{{end}}
</select>
<div class="form-text">This IP will be able to send emails for the selected domain</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {{if .ip_record.StoreMessageContent}}checked{{end}}>
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update IP Whitelist</button>
<a href="/pymta-manager/ips" 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 Configuration</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Current IP:</dt><dd class="col-sm-8"><code>{{.ip_record.IPAddress}}</code></dd>
<dt class="col-sm-4">Domain:</dt>
<dd class="col-sm-8">{{range .domains}}{{if eq .ID $.ip_record.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">{{if .ip_record.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-4">Store Message:</dt>
<dd class="col-sm-8">{{if .ip_record.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</dd>
<dt class="col-sm-4">Created:</dt><dd class="col-sm-8"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .ip_record.CreatedAt}}</small></dd>
</dl>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() { document.getElementById('ip_address').focus(); });
</script>
{{end}}
+79
View File
@@ -0,0 +1,79 @@
{{define "title"}}Edit Sender - 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-person-fill-gear me-2"></i>Edit Sender</h5></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" value="{{.local_part}}" required
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}}" {{if eq .ID $.sender.DomainID}}selected{{end}}>{{.DomainName}}</option>{{end}}
</select>
</div>
<div class="form-text">The sender always belongs to the domain selected here.</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</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as_domain" name="can_send_as_domain" {{if .sender.CanSendAsDomain}}checked{{end}}>
<label class="form-check-label" for="can_send_as_domain"><strong>Can send as any email from domain</strong></label>
<div class="form-text">Allow this sender to send emails using any address within their domain</div>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="store_message_content" name="store_message_content" {{if .sender.StoreMessageContent}}checked{{end}}>
<label class="form-check-label" for="store_message_content"><strong>Store Full Message Content</strong></label>
<div class="form-text">If enabled, the full message body and attachments will be stored and viewable in logs.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Sender</button>
<a href="/pymta-manager/senders" 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 Sender Details</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Email:</dt><dd class="col-sm-8"><code>{{.sender.Email}}</code></dd>
<dt class="col-sm-4">Domain:</dt>
<dd class="col-sm-8">{{range .domains}}{{if eq .ID $.sender.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">{{if .sender.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-4">Domain Sender:</dt>
<dd class="col-sm-8">{{if .sender.CanSendAsDomain}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Yes</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-x-circle me-1"></i>No</span>{{end}}</dd>
<dt class="col-sm-4">Store Message:</dt>
<dd class="col-sm-8">{{if .sender.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</dd>
<dt class="col-sm-4">Created:</dt><dd class="col-sm-8"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .sender.CreatedAt}}</small></dd>
</dl>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('local_part').focus();
});
</script>
{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "title"}}Error - SMTP Management{{end}}
{{define "content"}}
<div class="row">
<div class="col-12">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<div class="d-flex align-items-center">
<i class="fas fa-exclamation-triangle me-2"></i>
<h5 class="mb-0">Error Occurred</h5>
</div>
</div>
<div class="card-body">
{{if .error_code}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Error Code:</strong></div>
<div class="col-sm-9"><span class="badge bg-danger fs-6">{{.error_code}}</span></div>
</div>
{{end}}
{{if .error_message}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Message:</strong></div>
<div class="col-sm-9"><div class="alert alert-danger mb-0">{{.error_message}}</div></div>
</div>
{{end}}
{{if .error_details}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Details:</strong></div>
<div class="col-sm-9">
<div class="bg-dark text-light p-3 rounded">
<pre class="mb-0"><code>{{.error_details}}</code></pre>
</div>
</div>
</div>
{{end}}
<div class="row mb-3">
<div class="col-sm-3"><strong>Timestamp:</strong></div>
<div class="col-sm-9"><span class="text-muted">{{if .current_time}}{{strftime "%Y-%m-%d %H:%M:%S" .current_time}}{{else}}Unknown{{end}}</span></div>
</div>
<div class="row">
<div class="col-sm-3"><strong>Request URL:</strong></div>
<div class="col-sm-9"><code>{{dget . "request_url"}}</code></div>
</div>
</div>
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div>
<a href="/pymta-manager/" class="btn btn-primary">
<i class="fas fa-home me-1"></i>
Return to Dashboard
</a>
<button onclick="history.back()" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>
Go Back
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "title"}}Set up your account{{end}}
{{define "page_title"}}Set up your account{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
You're signed in with the default admin account. Choose a new username and password before continuing.
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Choose your credentials</h5></div>
<div class="card-body">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/pymta-manager/first-login">
<div class="mb-3">
<label for="username" class="form-label">New username</label>
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">New password</label>
<input type="password" class="form-control" id="password" name="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-4">
<label for="password_confirm" class="form-label">Confirm new password</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required minlength="10">
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save and continue</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+116
View File
@@ -0,0 +1,116 @@
{{define "title"}}Whitelisted IPs - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-router me-2"></i>Whitelisted IP Addresses</h2>
<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="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>
<tbody>
{{range .ips}}
{{$ip := index . 0}}{{$domain := index . 1}}
<tr>
<td><div class="fw-bold font-monospace">{{$ip.IPAddress}}</div></td>
<td><span class="badge bg-secondary">{{$domain.domain_name}}</span></td>
<td>{{if $ip.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>{{if $ip.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Stores Full Message</span>{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $ip.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/ips/{{$ip.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit IP"><i class="bi bi-pencil"></i></a>
{{if $ip.IsActive}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable IP" data-confirm="Disable {{$ip.IPAddress}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable IP" data-confirm="Enable {{$ip.IPAddress}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/ips/{{$ip.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove IP" data-confirm="Permanently remove {{$ip.IPAddress}}? 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-router text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No IP Addresses Whitelisted</h4>
<p class="text-muted">Add IP addresses to allow authentication without username/password</p>
<a href="/pymta-manager/ips/add" class="btn btn-primary"><i class="bi bi-plus-circle me-2"></i>Add First IP Address</a>
</div>
{{end}}
</div>
</div>
</div>
<div class="col-lg-4">
<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">
<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>
<li>Each IP is associated with a specific domain</li>
<li>IP can only send emails for its authorized domain</li>
<li>Useful for server-to-server email sending</li>
</ul>
</div>
</div>
</div>
<div class="card mt-3">
<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">
<div class="fw-bold font-monospace fs-5" id="current-ip"><span class="spinner-border spinner-border-sm me-2"></span>Detecting...</div>
<button class="btn btn-outline-primary btn-sm mt-2" onclick="addCurrentIP()"><i class="bi bi-plus-circle me-1"></i>Add This IP</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
async function detectCurrentIP() {
try {
const response = await fetch('https://ifconfig.me/all.json');
const data = await response.json();
document.getElementById('current-ip').innerHTML = `<span class="text-primary">${data.ip_addr}</span>`;
} catch (error) {
document.getElementById('current-ip').innerHTML = '<span class="text-muted">Unable to detect</span>';
}
}
function addCurrentIP() {
const currentIPElement = document.getElementById('current-ip');
const ip = currentIPElement.textContent.trim();
if (ip && ip !== 'Detecting...' && ip !== 'Unable to detect') {
const url = new URL('/pymta-manager/ips/add', window.location.origin);
url.searchParams.set('ip', ip);
window.location.href = url.toString();
} else {
showToast('Unable to detect current IP address', 'danger');
}
}
detectCurrentIP();
</script>
{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "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 - mailgoserver</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-envelope-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">mailgoserver</h4>
<p class="text-muted">Admin Dashboard</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="/pymta-manager/login">
<input type="hidden" name="next" value="{{.next}}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" value="{{.username}}" 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>
<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}}
+114
View File
@@ -0,0 +1,114 @@
{{define "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 - mailgoserver</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="/pymta-manager/login/mfa">
<input type="hidden" name="next" value="{{.next}}">
<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('/pymta-manager/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('/pymta-manager/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 = {{if .next}}'{{.next}}'{{else}}'/pymta-manager/'{{end}};
} catch (e) {
errEl.textContent = e.message || 'Passkey login failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
+157
View File
@@ -0,0 +1,157 @@
{{define "title"}}Logs - Email Server{{end}}
{{define "extra_css"}}
<style>
.log-entry { border-left: 4px solid var(--bs-border-color); padding: 0.75rem; margin-bottom: 0.5rem; background-color: var(--bs-body-bg); border-radius: 0.375rem; }
.log-email { border-left-color: #0d6efd; }
.log-auth { border-left-color: #198754; }
.log-success { border-left-color: #198754; }
.log-failed { border-left-color: #dc3545; }
.log-partial { border-left-color: #fd7e14; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-journal-text me-2"></i>Emails Log</h2>
<div class="btn-group">
<a href="/pymta-manager/logs?type=all" class="btn {{if eq .filter_type "all"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-list-ul me-1"></i>All Logs</a>
<a href="/pymta-manager/logs?type=emails" class="btn {{if eq .filter_type "emails"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-envelope me-1"></i>Email Logs</a>
<a href="/pymta-manager/logs?type=auth" class="btn {{if eq .filter_type "auth"}}btn-primary{{else}}btn-outline-primary{{end}}"><i class="bi bi-shield-lock me-1"></i>Auth Logs</a>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Recent Activity</h5>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
</div>
<div class="card-body">
{{if .logs}}
{{if eq .filter_type "all"}}
{{range .logs}}
{{if eq .type "email"}}
{{$log := .data}}
{{$overall := emailOverallStatus .recipients}}
<div class="log-entry log-email log-{{if eq $overall "relayed"}}success{{else if eq $overall "partial"}}partial{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-primary me-2">EMAIL</span>
<strong>{{$log.MailFrom}}</strong>
{{if $log.ToAddress}} &rarr; <span class="text-primary">To:</span> {{$log.ToAddress}}{{end}}
{{if $log.DKIMSigned}}<span class="badge bg-success ms-2"><i class="bi bi-shield-check me-1"></i>DKIM</span>{{end}}
</div>
<small class="text-muted">{{strftime "%Y-%m-%d %H:%M:%S" $log.Timestamp}}</small>
</div>
<div class="row">
<div class="col-md-6"><strong>Status:</strong> {{if eq $overall "relayed"}}<span class="text-success">Sent Successfully</span>{{else if eq $overall "partial"}}<span class="text-warning">Partial Fail</span>{{else}}<span class="text-danger">Failed</span>{{end}}</div>
<div class="col-md-6"><strong>Message ID:</strong> <code>{{$log.MessageID}}</code></div>
</div>
{{if $log.Subject}}<div class="mt-2"><strong>Subject:</strong> {{$log.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{$log.ID}}" class="btn btn-sm btn-primary"><i class="bi bi-envelope-open-text"></i> View Message Details</a></div>
</div>
{{else}}
{{$log := .data}}
<div class="log-entry log-auth log-{{if $log.Success}}success{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge bg-success me-2">AUTH</span>
<strong>{{$log.Identifier}}</strong>
<span class="badge {{if $log.Success}}bg-success{{else}}bg-danger{{end}} ms-2">{{if $log.Success}}Success{{else}}Failed{{end}}</span>
</div>
<small class="text-muted">{{formatDatetime $log.CreatedAt}}</small>
</div>
<div class="row">
<div class="col-md-6"><strong>Type:</strong> {{upper $log.AuthType}}</div>
<div class="col-md-6"><strong>IP:</strong> <code>{{if $log.IPAddress}}{{$log.IPAddress}}{{else}}N/A{{end}}</code></div>
</div>
{{if $log.Message}}<div class="mt-2"><strong>Message:</strong> {{$log.Message}}</div>{{end}}
</div>
{{end}}
{{end}}
{{else if eq .filter_type "emails"}}
{{$recMap := .recipient_logs_map}}
{{range .logs}}
{{$log := .}}
{{$recs := index $recMap .ID}}
{{$overall := emailOverallStatus $recs}}
<div class="log-entry log-email log-{{$overall}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{.MailFrom}}</strong>
{{if .ToAddress}} &rarr; <span class="text-primary">To:</span> {{.ToAddress}}{{end}}
{{if .CcAddresses}}<br><span class="ms-4 text-info">CC:</span> {{.CcAddresses}}{{end}}
{{if .BccAddresses}}<br><span class="ms-4 text-warning">BCC:</span> {{.BccAddresses}}{{end}}
{{if .DKIMSigned}}<span class="badge bg-success ms-2"><i class="bi bi-shield-check me-1"></i>DKIM</span>{{end}}
</div>
<small class="text-muted">{{strftime "%Y-%m-%d %H:%M:%S" .Timestamp}}</small>
</div>
<div class="row">
<div class="col-md-3"><strong>Status:</strong> {{if eq $overall "relayed"}}<span class="text-success">Sent</span>{{else if eq $overall "partial"}}<span class="text-warning">Partial Fail</span>{{else}}<span class="text-danger">Failed</span>{{end}}</div>
<div class="col-md-3"><strong>Peer:</strong> <code>{{.PeerIP}}</code></div>
<div class="col-md-6"><strong>Message ID:</strong> <code>{{.MessageID}}</code></div>
</div>
{{if $recs}}
<div class="mt-2">
<strong>Recipient Delivery Results:</strong>
<ul class="list-group">
{{range $recs}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><strong>{{upper .RecipientType}}:</strong> {{.Recipient}} {{if eq .Status "success"}}<span class="badge bg-success ms-2">Delivered</span>{{else}}<span class="badge bg-danger ms-2">Failed</span>{{end}}</span>
{{if or .ErrorCode .ErrorMessage}}<span class="text-danger ms-2">{{.ErrorCode}} {{.ErrorMessage}}</span>{{end}}
</li>
{{end}}
</ul>
</div>
{{end}}
{{if .Subject}}<div class="mt-2"><strong>Subject:</strong> {{.Subject}}</div>{{end}}
<div class="mt-2"><a href="/pymta-manager/msg/content/{{.ID}}" class="btn btn-outline-info btn-sm"><i class="bi bi-file-earmark-text me-1"></i> View Full Message</a></div>
</div>
{{end}}
{{else}}
{{range .logs}}
<div class="log-entry log-auth log-{{if .Success}}success{{else}}failed{{end}}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{.Identifier}}</strong>
<span class="badge {{if .Success}}bg-success{{else}}bg-danger{{end}} ms-2">{{if .Success}}Success{{else}}Failed{{end}}</span>
</div>
<small class="text-muted">{{formatDatetime .CreatedAt}}</small>
</div>
<div class="row">
<div class="col-md-4"><strong>Type:</strong> {{upper .AuthType}}</div>
<div class="col-md-4"><strong>IP:</strong> <code>{{if .IPAddress}}{{.IPAddress}}{{else}}N/A{{end}}</code></div>
<div class="col-md-4"><strong>Result:</strong> {{if .Success}}<span class="text-success">Authenticated</span>{{else}}<span class="text-danger">Rejected</span>{{end}}</div>
</div>
{{if .Message}}<div class="mt-2"><strong>Details:</strong> {{.Message}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if or .has_prev .has_next}}
<nav aria-label="Log pagination" class="mt-4">
<ul class="pagination justify-content-center">
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
<li class="page-item active"><span class="page-link">Page {{.page}}</span></li>
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
</ul>
</nav>
{{end}}
{{else}}
<div class="text-center py-5"><i class="bi bi-journal-text text-muted" style="font-size: 4rem;"></i><h4 class="text-muted mt-3">No Logs Found</h4></div>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script>
setInterval(function() { if (document.visibilityState === 'visible') { location.reload(); } }, 30000);
</script>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{define "title"}}Senders - Email Server Management{{end}}
{{define "page_title"}}Sender Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people me-2"></i>Senders</h2>
<a href="/pymta-manager/senders/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Sender</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Senders</h5></div>
<div class="card-body p-0">
{{if .senders}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Domain</th><th>Permissions</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .senders}}
{{$sender := index . 0}}{{$domain := index . 1}}
<tr>
<td><div class="fw-bold">{{$sender.Email}}</div></td>
<td><span class="badge bg-secondary">{{$domain.domain_name}}</span></td>
<td>
{{if $sender.CanSendAsDomain}}
<span class="badge bg-warning" style="color: black;"><i class="bi bi-star me-1"></i>Domain Sender</span><br>
<small class="text-muted">Can send as *@{{$domain.domain_name}}</small>
{{else}}
<span class="badge bg-info" style="color: black;"><i class="bi bi-person me-1"></i>Regular Sender</span><br>
<small class="text-muted">Can only send as {{$sender.Email}}</small>
{{end}}
</td>
<td>
{{if $sender.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>
{{if $sender.StoreMessageContent}}<span class="badge bg-info text-dark"><i class="bi bi-file-earmark-text me-1"></i>Stores Full Message</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-file-earmark me-1"></i>Headers Only</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $sender.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/senders/{{$sender.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Sender"><i class="bi bi-pencil"></i></a>
{{if $sender.IsActive}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Sender" data-confirm="Disable user {{$sender.Email}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable Sender" data-confirm="Enable user {{$sender.Email}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/senders/{{$sender.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove Sender" data-confirm="Permanently remove user {{$sender.Email}}? 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-people text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No senders configured</h4>
<p class="text-muted">Add sender to enable username/password authentication</p>
<a href="/pymta-manager/senders/add" class="btn btn-primary"><i class="bi bi-person-plus me-2"></i>Add Your First Sender</a>
</div>
{{end}}
</div>
</div>
{{end}}
+277
View File
@@ -0,0 +1,277 @@
{{define "title"}}Server Settings - Email Server{{end}}
{{define "extra_css"}}
<style>
.setting-section { border-left: 4px solid var(--bs-primary); padding-left: 1rem; margin-bottom: 2rem; }
.setting-description { font-size: 0.875rem; color: var(--bs-secondary); margin-bottom: 0.5rem; }
</style>
{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-sliders me-2"></i>Server Settings</h2>
<div class="btn-group">
<button type="button" class="btn btn-outline-info" onclick="exportSettings()"><i class="bi bi-download me-2"></i>Export Config</button>
</div>
</div>
<form method="POST" action="/pymta-manager/settings_update" id="settingsForm">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-server me-2"></i>Server Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">SMTP Port</label>
<div class="setting-description">Port for plain/IP-whitelisted SMTP connections</div>
<input type="number" class="form-control" name="Server.smtp_port" value="{{.settings.Server.smtp_port}}" min="1" max="65535">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">SMTP TLS Port</label>
<div class="setting-description">Port for direct-TLS authenticated SMTP connections</div>
<input type="number" class="form-control" name="Server.smtp_tls_port" value="{{.settings.Server.smtp_tls_port}}" min="1" max="65535">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Bind IP Address</label>
<input type="text" class="form-control" name="Server.bind_ip" value="{{.settings.Server.bind_ip}}">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">Server Timezone</label>
<select class="form-select" name="Server.time_zone">
{{$currentTZ := .settings.Server.time_zone}}
{{range .timezones}}<option value="{{.}}" {{if eq . $currentTZ}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Hostname</label>
<input type="text" class="form-control" name="Server.hostname" value="{{.settings.Server.hostname}}">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">HELO Hostname</label>
<input type="text" class="form-control" name="Server.helo_hostname" value="{{.settings.Server.helo_hostname}}">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Server Banner</label>
<div class="setting-description">Custom SMTP banner (empty by default)</div>
<input type="text" class="form-control" name="Server.server_banner" value="{{.settings.Server.server_banner}}">
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-database me-2"></i>Database Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3">
<label class="form-label">Database URL</label>
<div class="input-group mb-2">
<input type="text" class="form-control font-monospace" name="Database.database_url" id="databaseUrl" value="{{.settings.Database.database_url}}">
<button class="btn btn-primary" type="button" onclick="testDatabaseConnection()"><i class="bi bi-check-circle me-1"></i>Test Connection</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-journal-text me-2"></i>Logging Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Log Level</label>
<select class="form-select" name="Logging.log_level">
{{$lvl := .settings.Logging.log_level}}
{{range (list "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL")}}<option value="{{.}}" {{if eq . $lvl}}selected{{end}}>{{.}}</option>{{end}}
</select>
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">Hide aiosmtpd-equivalent INFO Messages</label>
<select class="form-select" name="Logging.hide_info_aiosmtpd">
<option value="true" {{if eq .settings.Logging.hide_info_aiosmtpd "true"}}selected{{end}}>Yes</option>
<option value="false" {{if eq .settings.Logging.hide_info_aiosmtpd "false"}}selected{{end}}>No</option>
</select>
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>Email Relay Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">Relay Timeout (seconds)</label>
<input type="number" class="form-control" name="Relay.relay_timeout" value="{{.settings.Relay.relay_timeout}}" min="5" max="300">
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Certificate File</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_cert_file" value="{{.settings.TLS.tls_cert_file}}">
<input type="file" class="d-none" id="certFileUpload" accept=".crt,.pem">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('certFileUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">TLS Private Key File</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="TLS.tls_key_file" value="{{.settings.TLS.tls_key_file}}">
<input type="file" class="d-none" id="keyFileUpload" accept=".key,.pem">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('keyFileUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>DKIM Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">DKIM Key Size</label>
<select class="form-select" name="DKIM.dkim_key_size">
{{$ks := .settings.DKIM.dkim_key_size}}
<option value="1024" {{if eq $ks "1024"}}selected{{end}}>1024 bits</option>
<option value="2048" {{if eq $ks "2048"}}selected{{end}}>2048 bits (Recommended)</option>
<option value="4096" {{if eq $ks "4096"}}selected{{end}}>4096 bits</option>
</select>
</div>
<div class="mb-3"><label class="form-label">SPF Server IP</label>
<div class="input-group">
<input type="text" class="form-control" name="DKIM.spf_server_ip" value="{{.settings.DKIM.spf_server_ip}}">
<button class="btn btn-danger" type="button" onclick="getPublicIP()"><i class="bi bi-cloud-download me-1"></i>Get Public IP</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-paperclip me-2"></i>Attachments Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="mb-3"><label class="form-label">Attachments Storage Path</label>
<input type="text" class="form-control" name="Attachments.attachments_path" value="{{.settings.Attachments.attachments_path}}" placeholder="server_data/attachments">
<div class="setting-description text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Make sure the path exists and is writable by the server process</div>
<div id="attachments-path-feedback" class="mt-2"></div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="alert alert-warning d-flex align-items-center mb-0"><i class="bi bi-exclamation-triangle me-2"></i><small>Server restart required after changing settings</small></div>
<button type="submit" class="btn btn-primary btn-lg"><i class="bi bi-save me-2"></i>Save Settings</button>
</div>
</form>
</div>
{{end}}
{{define "extra_js"}}
<script>
function exportSettings() {
const settings = {};
const formData = new FormData(document.querySelector('form'));
for (let [key, value] of formData.entries()) {
const [section, setting] = key.split('.');
if (!settings[section]) { settings[section] = {}; }
settings[section][setting] = value;
}
let config = '';
for (const [section, values] of Object.entries(settings)) {
config += `[${section}]\n`;
for (const [key, value] of Object.entries(values)) { config += `${key} = ${value}\n`; }
config += '\n';
}
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(config));
element.setAttribute('download', 'settings.ini');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
document.querySelector('form').addEventListener('submit', function(e) {
const ports = ['Server.smtp_port', 'Server.smtp_tls_port'];
for (const portField of ports) {
const input = document.querySelector(`[name="${portField}"]`);
const port = parseInt(input.value);
if (port < 1 || port > 65535) { e.preventDefault(); showToast(`Invalid port number: ${port}.`, 'danger'); input.focus(); return; }
}
const smtpPort = document.querySelector('[name="Server.smtp_port"]').value;
const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value;
if (smtpPort === tlsPort) { e.preventDefault(); showToast('SMTP and TLS ports must be different.', 'danger'); return; }
const serverBanner = document.querySelector('[name="Server.server_banner"]');
if (serverBanner && !serverBanner.value.trim()) { serverBanner.value = '""'; }
const attachmentsPath = document.querySelector('input[name="Attachments.attachments_path"]');
if (!attachmentsPath.value.trim()) { e.preventDefault(); showToast('Please specify a valid attachments storage path', 'danger'); attachmentsPath.focus(); }
});
document.getElementById('certFileUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('cert_file', file);
fetch('/pymta-manager/api/settings/upload_cert', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.querySelector('[name="TLS.tls_cert_file"]').value = data.filepath; showToast('Certificate uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload certificate', 'danger'); }
}).catch(() => showToast('Failed to upload certificate', 'danger'));
});
document.getElementById('keyFileUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('key_file', file);
fetch('/pymta-manager/api/settings/upload_key', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.querySelector('[name="TLS.tls_key_file"]').value = data.filepath; showToast('Key uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload key', 'danger'); }
}).catch(() => showToast('Failed to upload key', 'danger'));
});
function testDatabaseConnection() {
const url = document.getElementById('databaseUrl').value;
fetch('/pymta-manager/api/settings/test_database', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({url}) })
.then(r => r.json())
.then(data => showToast(data.message || (data.status === 'success' ? 'Connection successful!' : 'Failed to connect'), data.status === 'success' ? 'success' : 'danger'))
.catch(() => showToast('Failed to test database connection', 'danger'));
}
function getPublicIP() {
fetch('/pymta-manager/api/settings/get_public_ip')
.then(r => r.json())
.then(data => {
if (data.ip) { document.querySelector('[name="DKIM.spf_server_ip"]').value = data.ip; showToast('Public IP fetched', 'success'); }
else { showToast('Failed to fetch public IP', 'danger'); }
}).catch(() => showToast('Failed to fetch public IP', 'danger'));
}
function validateAttachmentsPath() {
const path = document.querySelector('input[name="Attachments.attachments_path"]').value;
const feedback = document.getElementById('attachments-path-feedback');
if (!feedback) return;
fetch('/pymta-manager/test_attachments_path', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: `path=${encodeURIComponent(path)}` })
.then(r => r.json())
.then(data => {
feedback.innerHTML = data.message + (data.success ? `<br><small class="text-muted">Absolute path: ${data.absolute_path}</small>` : '');
feedback.className = data.success ? 'text-success mt-2' : 'text-danger mt-2';
}).catch(error => { feedback.innerHTML = `Error validating path: ${error}`; feedback.className = 'text-danger mt-2'; });
}
document.querySelector('input[name="Attachments.attachments_path"]')?.addEventListener('change', validateAttachmentsPath);
</script>
{{end}}
+150
View File
@@ -0,0 +1,150 @@
{{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>
<div class="flex-grow-1 overflow-auto">
<ul class="nav nav-pills flex-column p-3">
<li class="nav-item mb-2">
<a href="/pymta-manager/" class="nav-link text-white {{if eq (dget . "active") "dashboard"}}active{{end}}">
<i class="bi bi-speedometer2 me-2"></i>
Dashboard
</a>
</li>
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-globe me-1"></i>
Email Server Management
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/domains" class="nav-link text-white {{if eq (dget . "active") "domains"}}active{{end}}">
<i class="bi bi-list-ul me-2"></i>
Domains
<span class="badge bg-secondary ms-auto">{{dget . "domain_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/senders" class="nav-link text-white {{if eq (dget . "active") "senders"}}active{{end}}">
<i class="bi bi-people me-2"></i>
Allowed Senders
<span class="badge bg-secondary ms-auto">{{dget . "sender_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>
Whitelisted IPs
<span class="badge bg-secondary ms-auto">{{dget . "ip_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/dkim" class="nav-link text-white {{if eq (dget . "active") "dkim"}}active{{end}}">
<i class="bi bi-shield-check me-2"></i>
DKIM Keys
<span class="badge bg-secondary ms-auto">{{dget . "dkim_count"}}</span>
</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>
Emails Log
</a>
</li>
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-gear me-1"></i>
Configuration
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/settings" class="nav-link text-white {{if eq (dget . "active") "settings"}}active{{end}}">
<i class="bi bi-sliders me-2"></i>
Server Settings
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/admins" class="nav-link text-white {{if eq (dget . "active") "admins"}}active{{end}}">
<i class="bi bi-people-fill me-2"></i>
Admins
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/account" class="nav-link text-white {{if eq (dget . "active") "account"}}active{{end}}">
<i class="bi bi-person-circle me-2"></i>
Account
</a>
</li>
<li class="nav-item mb-1">
<form method="post" action="/pymta-manager/logout">
<button type="submit" class="nav-link text-white w-100 text-start border-0 bg-transparent">
<i class="bi bi-box-arrow-right me-2"></i>
Sign out
</button>
</form>
</li>
</ul>
</div>
<div class="p-3 border-top border-secondary">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<small class="text-muted d-block">Server Status</small>
<small class="{{if eq .health.Status "healthy"}}text-success{{else}}text-warning{{end}} status-indicator"
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="top"
title="{{safe (printf "<div class='text-start'><strong>Service Status:</strong><br>SMTP Server: %s<br>Web Frontend: %s<br>Database: %s</div>" (title .health.Services.smtp_server) (title .health.Services.web_frontend) (title .health.Services.database))}}">
<i class="bi bi-circle-fill me-1" style="font-size: 0.5rem;"></i>
{{title .health.Status}}
</small>
</div>
<button class="btn btn-outline-secondary btn-sm" title="Refresh Status" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</div>
</nav>
<style>
.sidebar .nav-link { border-radius: 0.375rem; padding: 0.75rem 1rem; margin-bottom: 0.25rem; transition: all 0.2s ease; }
.sidebar .nav-link:hover { background-color: rgba(255, 255, 255, 0.1); transform: translateX(4px); }
.sidebar .nav-link.active { background-color: #0d6efd; color: white !important; }
.sidebar .nav-link.active:hover { background-color: #0b5ed7; }
.sidebar h6 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.05em; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 0.5rem; margin-bottom: 1rem !important; }
.sidebar .badge { font-size: 0.7rem; }
.status-indicator { cursor: pointer; }
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); transition: transform 0.3s ease; }
.sidebar.show { transform: translateX(0); }
.content-area { margin-left: 0 !important; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl, { html: true, placement: 'top', trigger: 'hover' });
});
});
</script>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "title"}}Set up authenticator app{{end}}
{{define "page_title"}}Set up authenticator app{{end}}
{{define "content"}}
<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="/pymta-manager/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="/pymta-manager/account" 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>
{{end}}
@@ -0,0 +1,49 @@
{{define "title"}}View Full Message - Email Log{{end}}
{{define "content"}}
<div class="container mt-4">
<h2>Full Message Content</h2>
<div class="mb-3">
<strong>From:</strong> {{.log.mail_from}}<br>
<strong>To:</strong> {{.log.to_address}}<br>
<strong>CC:</strong> {{if .log.cc_addresses}}{{.log.cc_addresses}}{{else}}None{{end}}<br>
<strong>BCC:</strong> {{if .log.bcc_addresses}}{{.log.bcc_addresses}}{{else}}None{{end}}<br>
<strong>Subject:</strong> {{if .log.subject}}{{.log.subject}}{{else}}N/A{{end}}<br>
<strong>Date:</strong> {{strftime "%Y-%m-%d %H:%M:%S" .log.created_at}}<br>
</div>
{{if .log.attachments}}
<div class="card mb-3">
<div class="card-header"><strong>Attachments:</strong></div>
<div class="card-body">
<ul class="list-group">
{{range .log.attachments}}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div><i class="fas fa-paperclip"></i> {{.Filename}} <small class="text-muted">({{filesize .Size}})</small></div>
<div class="btn-group" role="group">
<a href="/pymta-manager/msg/attachment/{{.ID}}/download" class="btn btn-sm btn-outline-primary" target="_blank" title="Open in new tab"><i class="fas fa-external-link-alt"></i> View</a>
<a href="/pymta-manager/msg/attachment/{{.ID}}/download?download=true" class="btn btn-sm btn-outline-secondary" title="Download file"><i class="fas fa-download"></i> Download</a>
<form method="POST" action="/pymta-manager/msg/attachment/{{.ID}}/delete" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete attachment" data-confirm="Are you sure you want to delete this attachment?"><i class="fas fa-trash-alt"></i> Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
<div class="card">
<div class="card-header"><strong>Message Content:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap; word-break: break-all;">{{.log.message_body}}</pre></div>
</div>
<div class="card mt-3">
<div class="card-header"><strong>Message Headers:</strong></div>
<div class="card-body"><pre style="white-space: pre-wrap;">{{.log.email_headers}}</pre></div>
</div>
<a href="/pymta-manager/logs?type=emails" class="btn btn-secondary mt-3">Back to Logs</a>
</div>
{{end}}