411 lines
18 KiB
HTML
411 lines
18 KiB
HTML
{{define "title"}}Domain — {{.Domain.Name}}{{end}}
|
|
{{define "content"}}
|
|
<div class="flex items-center gap-3 mb-6">
|
|
<a href="/admin/domains" class="text-gray-400 text-sm hover:text-white">Domains</a>
|
|
<span class="text-gray-600">/</span>
|
|
<h1 class="text-xl font-bold text-white">{{.Domain.Name}}</h1>
|
|
{{if .Domain.Enabled}}<span class="badge badge-green">enabled</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
|
</div>
|
|
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
|
|
|
|
<!-- Left column -->
|
|
<div>
|
|
<!-- Toggle enable -->
|
|
<div class="card">
|
|
<div class="text-sm font-semibold text-gray-300 mb-3">Enable / Disable</div>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/enable">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
{{if .Domain.Enabled}}
|
|
<input type="hidden" name="enabled" value="0">
|
|
<button type="submit" class="btn btn-danger">Disable domain</button>
|
|
{{else}}
|
|
<input type="hidden" name="enabled" value="1">
|
|
<button type="submit" class="btn btn-primary">Enable domain</button>
|
|
{{end}}
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Limits -->
|
|
<div class="card">
|
|
<div class="text-sm font-semibold text-gray-300 mb-3">Limits</div>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/limits">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<div class="field">
|
|
<label>Max users (0 = unlimited)</label>
|
|
<input type="number" name="max_users" value="{{.Domain.MaxUsers}}" min="0" max="100000">
|
|
</div>
|
|
<div class="field">
|
|
<label>Max quota per user (MB, 0 = unlimited)</label>
|
|
<input type="number" name="max_quota_mb" value="{{mb .Domain.MaxQuotaBytes}}" min="0" max="1048576">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm">Save limits</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Delete -->
|
|
<div class="card" style="border:1px solid #7f1d1d">
|
|
<div class="text-sm font-semibold text-red-400 mb-2">Delete domain</div>
|
|
<div class="text-xs text-gray-400 mb-3">Permanently deletes the domain and ALL users and messages within it. This cannot be undone.</div>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/delete"
|
|
onsubmit="return confirm('Delete domain {{.Domain.Name}} and all its data?')">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<button type="submit" class="btn btn-danger btn-sm">Delete domain</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right column -->
|
|
<div>
|
|
<!-- DKIM key management -->
|
|
<div class="card">
|
|
<div class="text-sm font-semibold text-gray-300 mb-3">DKIM key</div>
|
|
{{if .Domain.DKIMPublic}}
|
|
<div class="text-xs text-gray-400 mb-2">
|
|
Selector: <span class="text-white">{{.Domain.DKIMSelector}}</span> /
|
|
Algorithm: <span class="text-white">{{.Domain.DKIMAlgo}}</span>
|
|
</div>
|
|
{{else}}
|
|
<div class="text-xs text-yellow-400 mb-3">No DKIM key generated yet.</div>
|
|
{{end}}
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dkim">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<div class="field">
|
|
<label>Algorithm for new key</label>
|
|
<select name="algo">
|
|
<option value="rsa2048" {{if eq .Domain.DKIMAlgo "rsa2048"}}selected{{end}}>RSA-2048</option>
|
|
<option value="ed25519" {{if eq .Domain.DKIMAlgo "ed25519"}}selected{{end}}>Ed25519</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm"
|
|
onclick="return !{{if .Domain.DKIMPublic}}confirm('Regenerate DKIM key? Old signatures become invalid.'){{else}}false{{end}}">
|
|
{{if .Domain.DKIMPublic}}Regenerate DKIM key{{else}}Generate DKIM key{{end}}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- DMARC Monitoring -->
|
|
<div class="card">
|
|
<div class="text-sm font-semibold text-gray-300 mb-3">DMARC Monitoring</div>
|
|
{{if .Domain.DMARCRua}}
|
|
<div class="text-xs text-gray-400 mb-1">Monitoring address</div>
|
|
<div style="font-family:monospace;font-size:.75rem;color:#60a5fa;word-break:break-all;margin-bottom:.75rem">{{.Domain.DMARCRua}}</div>
|
|
<div class="text-xs text-gray-500 mb-3">
|
|
Add this address to your DMARC DNS record as <span style="font-family:monospace;color:#9ca3af">rua=mailto:{{.Domain.DMARCRua}}</span>.
|
|
External mail servers will send aggregate reports here every 24 hours.
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<a href="/admin/domains/{{.Domain.ID}}/dmarc" class="btn btn-primary btn-sm">
|
|
View reports{{if .DMARCReportCount}} ({{.DMARCReportCount}}){{end}}
|
|
</a>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dmarc/disable"
|
|
onsubmit="return confirm('Disable DMARC monitoring? Incoming reports will no longer be processed.')">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<button type="submit" class="btn btn-danger btn-sm">Disable</button>
|
|
</form>
|
|
</div>
|
|
{{else}}
|
|
<div class="text-xs text-gray-400 mb-3">
|
|
Enable monitoring to receive DMARC aggregate reports from external mail servers.
|
|
A unique email address will be generated for this domain and reports delivered to it will be parsed and stored.
|
|
</div>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/dmarc/enable">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<button type="submit" class="btn btn-primary btn-sm">Enable DMARC monitoring</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
<!-- DNS Records -->
|
|
<div class="card">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="text-sm font-semibold text-gray-300">DNS Records</div>
|
|
<button onclick="checkDNS({{.Domain.ID}}, this)" class="btn btn-primary btn-sm">Check DNS</button>
|
|
</div>
|
|
|
|
<div class="text-xs font-semibold text-gray-400 mb-2" style="text-transform:uppercase;letter-spacing:.05em">Required</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">MX</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.MXRecord}}</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">SPF</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.SPFHint}}</div>
|
|
|
|
{{if .DKIMRecord}}
|
|
<div class="text-xs text-gray-400 mb-1">DKIM (selector: {{.Domain.DKIMSelector}})</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.DKIMRecord}}</div>
|
|
{{else}}
|
|
<div class="text-xs text-yellow-400 mb-3">Generate a DKIM key above to get the DKIM DNS record.</div>
|
|
{{end}}
|
|
|
|
<div class="text-xs text-gray-400 mb-1">DMARC</div>
|
|
<div class="dns-record" style="margin-bottom:.625rem">{{.DMARCHint}}</div>
|
|
{{if .Domain.DMARCRua}}
|
|
<div class="text-xs text-gray-500 mb-1">Monitoring address: <span style="color:#60a5fa;font-family:monospace">{{.Domain.DMARCRua}}</span></div>
|
|
{{end}}
|
|
<div style="margin-bottom:1rem"></div>
|
|
|
|
<div class="text-xs font-semibold text-gray-400 mb-2" style="text-transform:uppercase;letter-spacing:.05em">Optional (autoconfiguration)</div>
|
|
<div class="text-xs text-gray-500 mb-2">Allows mail clients (Thunderbird, Outlook) to auto-discover server settings.</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">Thunderbird autoconfig (CNAME)</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.AutoconfigCNAME}}</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">Outlook autodiscover (CNAME)</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.AutodiscoverCNAME}}</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">SMTP submission (SRV, RFC 6186)</div>
|
|
<div class="dns-record" style="margin-bottom:.75rem">{{.SMTPSRVRecord}}</div>
|
|
|
|
<div class="text-xs text-gray-400 mb-1">IMAP SSL (SRV, RFC 6186)</div>
|
|
<div class="dns-record">{{.IMAPSRVRecord}}</div>
|
|
|
|
<!-- DNS check results injected here -->
|
|
<div id="dns-check-{{.Domain.ID}}" style="display:none;margin-top:1.25rem;padding-top:1rem;border-top:1px solid #374151"></div>
|
|
</div>
|
|
|
|
<!-- IP Relay Rules -->
|
|
<div class="card">
|
|
<div class="text-sm font-semibold text-gray-300 mb-2">IP Relay Rules</div>
|
|
<div class="text-xs text-gray-400 mb-3">
|
|
Allow specific IP addresses (or CIDR ranges) to submit email for this domain
|
|
without SMTP authentication. Optionally restrict to specific sender addresses.
|
|
</div>
|
|
{{if .RelayIPRules}}
|
|
<div style="margin-bottom:.75rem;overflow:auto">
|
|
<table style="font-size:.7rem;width:100%;border-collapse:collapse">
|
|
<thead>
|
|
<tr style="color:#6b7280;text-align:left;border-bottom:1px solid #374151">
|
|
<th style="padding:.25rem .5rem">IP / CIDR</th>
|
|
<th style="padding:.25rem .5rem">Sender pattern</th>
|
|
<th style="padding:.25rem .5rem">Description</th>
|
|
<th style="padding:.25rem .5rem"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .RelayIPRules}}
|
|
<tr style="border-bottom:1px solid #1f2937">
|
|
<td style="padding:.25rem .5rem;font-family:monospace;color:#93c5fd">{{.CIDR}}</td>
|
|
<td style="padding:.25rem .5rem;font-family:monospace;color:#a78bfa">{{.SenderPattern}}</td>
|
|
<td style="padding:.25rem .5rem;color:#9ca3af">{{.Description}}</td>
|
|
<td style="padding:.25rem .5rem;text-align:right">
|
|
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/iprelay/{{.ID}}/delete" style="margin:0"
|
|
onsubmit="return confirm('Remove this IP relay rule?')">
|
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
|
<button type="submit" class="btn btn-danger btn-sm" style="padding:.125rem .5rem;font-size:.7rem">Remove</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{else}}
|
|
<div class="text-xs text-gray-500 mb-3">No IP relay rules configured.</div>
|
|
{{end}}
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/iprelay/add">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
|
<div class="field" style="margin:0">
|
|
<label style="font-size:.7rem">IP or CIDR</label>
|
|
<input type="text" name="cidr" required placeholder="10.0.0.1 or 10.0.0.0/24" maxlength="50">
|
|
</div>
|
|
<div class="field" style="margin:0">
|
|
<label style="font-size:.7rem">Sender pattern</label>
|
|
<input type="text" name="sender_pattern" required placeholder="*@{{.Domain.Name}}" maxlength="255">
|
|
</div>
|
|
</div>
|
|
<div class="field" style="margin:.5rem 0 .75rem">
|
|
<label style="font-size:.7rem">Description (optional)</label>
|
|
<input type="text" name="description" placeholder="Internal mail server" maxlength="255">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-sm">Add rule</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users -->
|
|
<div class="mt-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-sm font-semibold text-gray-300">Users ({{len .Users}})</h2>
|
|
<a href="/admin/users" class="btn btn-primary btn-sm">Add user</a>
|
|
</div>
|
|
<div class="card" style="padding:0;overflow:hidden">
|
|
{{if .Users}}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Email</th>
|
|
<th>Display name</th>
|
|
<th>Status</th>
|
|
<th>Role</th>
|
|
<th>Quota used</th>
|
|
<th>Last login</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Users}}
|
|
<tr>
|
|
<td><a href="/admin/users/{{.ID}}" class="text-blue-400 hover:underline">{{.Email}}</a></td>
|
|
<td class="text-gray-300">{{.DisplayName}}</td>
|
|
<td>
|
|
{{if .Enabled}}<span class="badge badge-green">active</span>
|
|
{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
|
</td>
|
|
<td>
|
|
{{if .Admin}}<span class="badge badge-yellow">admin</span>
|
|
{{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span>
|
|
{{else}}<span class="badge badge-gray">user</span>{{end}}
|
|
</td>
|
|
<td class="text-gray-400 text-xs">{{humanBytes .UsedBytes}} / {{if .QuotaBytes}}{{humanBytes .QuotaBytes}}{{else}}unlimited{{end}}</td>
|
|
<td class="text-gray-400 text-xs">{{if isZero .LastLogin}}never{{else}}{{shortTime .LastLogin}}{{end}}</td>
|
|
<td><a href="/admin/users/{{.ID}}" class="btn btn-primary btn-sm">Edit</a></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<div class="p-6 text-center text-gray-500 text-sm">No users in this domain.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Relay Accounts -->
|
|
<div class="mt-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h2 class="text-sm font-semibold text-gray-300">Relay Accounts ({{len .RelayAccounts}})</h2>
|
|
</div>
|
|
<div class="card" style="padding:0;overflow:hidden">
|
|
{{if .RelayAccounts}}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Display name</th>
|
|
<th>Status</th>
|
|
<th>Description</th>
|
|
<th>Created</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .RelayAccounts}}
|
|
<tr>
|
|
<td style="font-family:monospace;font-size:.8rem">
|
|
<a href="/admin/domains/{{$.Domain.ID}}/relayaccount/{{.ID}}" class="text-blue-400 hover:underline">{{.Username}}</a>
|
|
</td>
|
|
<td class="text-gray-300">{{.DisplayName}}</td>
|
|
<td>
|
|
{{if .Enabled}}<span class="badge badge-green">active</span>
|
|
{{else}}<span class="badge badge-red">disabled</span>{{end}}
|
|
</td>
|
|
<td class="text-gray-400 text-xs">{{.Description}}</td>
|
|
<td class="text-gray-400 text-xs">{{shortTime .CreatedAt}}</td>
|
|
<td><a href="/admin/domains/{{$.Domain.ID}}/relayaccount/{{.ID}}" class="btn btn-primary btn-sm">Manage</a></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<div class="p-6 text-center text-gray-500 text-sm">No relay accounts configured.</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<!-- Create relay account form -->
|
|
<div class="card mt-3">
|
|
<div class="text-sm font-semibold text-gray-300 mb-3">Create relay account</div>
|
|
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount">
|
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
|
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:.75rem;margin-bottom:.75rem">
|
|
<div class="field" style="margin:0">
|
|
<label>Username (email or arbitrary string, max 240 chars)</label>
|
|
<input type="text" name="username" required maxlength="240" placeholder="relay@example.com or myapp-relay">
|
|
</div>
|
|
<div class="field" style="margin:0">
|
|
<label>Display name</label>
|
|
<input type="text" name="display_name" maxlength="255" placeholder="My App">
|
|
</div>
|
|
<div class="field" style="margin:0">
|
|
<label>Password (min 8 chars)</label>
|
|
<input type="password" name="password" required minlength="8" maxlength="1024">
|
|
</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:2fr auto;gap:.75rem;align-items:flex-end">
|
|
<div class="field" style="margin:0">
|
|
<label>Description (optional)</label>
|
|
<input type="text" name="description" maxlength="255" placeholder="Internal application relay">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Create</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.dns-record {
|
|
background: #111827;
|
|
border-radius: .375rem;
|
|
padding: .5rem .625rem;
|
|
font-size: .7rem;
|
|
color: #93c5fd;
|
|
word-break: break-all;
|
|
line-height: 1.6;
|
|
font-family: monospace;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
function checkDNS(id, btn) {
|
|
var orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Checking...';
|
|
var div = document.getElementById('dns-check-' + id);
|
|
div.style.display = 'block';
|
|
div.innerHTML = '<div style="font-size:.75rem;color:#9ca3af">Performing DNS lookups...</div>';
|
|
|
|
fetch('/admin/domains/' + id + '/dnscheck')
|
|
.then(function(r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.json();
|
|
})
|
|
.then(function(data) {
|
|
var html = '<div style="font-size:.75rem;font-weight:600;color:#d1d5db;margin-bottom:.625rem">DNS Check Results</div>';
|
|
html += dnsRow('MX', data.mx);
|
|
html += dnsRow('SPF', data.spf);
|
|
html += dnsRow('DKIM', data.dkim);
|
|
html += dnsRow('DMARC', data.dmarc);
|
|
div.innerHTML = html;
|
|
})
|
|
.catch(function(e) {
|
|
div.innerHTML = '<div style="font-size:.75rem;color:#f87171">DNS check failed: ' + esc(e.message) + '</div>';
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
});
|
|
}
|
|
|
|
function dnsRow(name, rec) {
|
|
var colors = {ok:'#6ee7b7', warn:'#fbbf24', error:'#f87171', missing:'#9ca3af'};
|
|
var labels = {ok:'OK', warn:'WARN', error:'FAIL', missing:'MISSING'};
|
|
var c = colors[rec.status] || '#9ca3af';
|
|
var l = labels[rec.status] || rec.status.toUpperCase();
|
|
var h = '<div style="margin-bottom:.5rem;font-size:.75rem">';
|
|
h += '<span style="color:' + c + ';font-weight:700;font-family:monospace">[' + l + ']</span> ';
|
|
h += '<span style="color:#d1d5db;font-weight:600">' + esc(name) + '</span> ';
|
|
h += '<span style="color:#9ca3af">' + esc(rec.message) + '</span>';
|
|
if (rec.found) {
|
|
h += '<div style="color:#6b7280;margin-top:.25rem;word-break:break-all;padding-left:1rem;font-size:.7rem;font-family:monospace">' + esc(rec.found) + '</div>';
|
|
}
|
|
h += '</div>';
|
|
return h;
|
|
}
|
|
|
|
function esc(s) {
|
|
return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
{{end}}
|