admin dash - added relay

This commit is contained in:
2026-05-25 17:29:15 +00:00
parent 3d46ccde33
commit fdda0cae34
14 changed files with 1063 additions and 198 deletions
+1
View File
@@ -44,6 +44,7 @@
<a href="/admin/" class="nav-link">Dashboard</a>
<a href="/admin/domains" class="nav-link">Domains</a>
<a href="/admin/users" class="nav-link">Users</a>
<a href="/admin/relay" class="nav-link">Relay</a>
<a href="/admin/queue" class="nav-link">Delivery Queue</a>
<a href="/admin/bans" class="nav-link">IP Bans</a>
<a href="/admin/events" class="nav-link">Security Events</a>
+73 -2
View File
@@ -189,7 +189,7 @@
<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}}/relay/{{.ID}}/delete" style="margin:0"
<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>
@@ -203,7 +203,7 @@
{{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}}/relay/add">
<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">
@@ -272,6 +272,77 @@
</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;
+54
View File
@@ -0,0 +1,54 @@
{{define "title"}}Relay Accounts{{end}}
{{define "content"}}
<h1 class="text-xl font-bold text-white mb-6">Relay Accounts</h1>
<div class="text-xs text-gray-400 mb-4">
Relay accounts allow external applications and servers to send mail via this server using SMTP AUTH.
They have no IMAP mailboxes. Managed per domain — click an account to configure send-as permissions.
</div>
<div class="card" style="padding:0;overflow:hidden">
{{if .Accounts}}
<table>
<thead>
<tr>
<th>Username</th>
<th>Domain</th>
<th>Display name</th>
<th>Status</th>
<th>Description</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Accounts}}
<tr>
<td style="font-family:monospace;font-size:.8rem">
<a href="/admin/domains/{{.DomainID}}/relayaccount/{{.ID}}" class="text-blue-400 hover:underline">{{.Username}}</a>
</td>
<td><a href="/admin/domains/{{.DomainID}}" class="text-gray-400 hover:text-white text-xs">{{.DomainName}}</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/{{.DomainID}}/relayaccount/{{.ID}}" class="btn btn-primary btn-sm">Manage</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="p-8 text-center text-gray-500 text-sm">
No relay accounts configured. Go to a <a href="/admin/domains" class="text-blue-400 hover:underline">Domain</a> to create one.
</div>
{{end}}
</div>
<div class="mt-4 text-xs text-gray-500">
To create relay accounts, navigate to a domain and use the Relay Accounts section.
IP relay rules (unauthenticated sending by IP) are also configured per domain.
</div>
{{end}}
+147
View File
@@ -0,0 +1,147 @@
{{define "title"}}Relay Account — {{.Account.Username}}{{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>
<a href="/admin/domains/{{.Domain.ID}}" class="text-gray-400 text-sm hover:text-white">{{.Domain.Name}}</a>
<span class="text-gray-600">/</span>
<h1 class="text-xl font-bold text-white" style="font-family:monospace">{{.Account.Username}}</h1>
{{if .Account.Enabled}}<span class="badge badge-green">active</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>
<!-- Enable/disable -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Status</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/toggle">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .Account.Enabled}}
<input type="hidden" name="enabled" value="0">
<button type="submit" class="btn btn-danger btn-sm">Disable account</button>
{{else}}
<input type="hidden" name="enabled" value="1">
<button type="submit" class="btn btn-primary btn-sm">Enable account</button>
{{end}}
</form>
</div>
<!-- Change password -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Change password</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/password">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field">
<label>New password (min 8 characters)</label>
<input type="password" name="password" required minlength="8" maxlength="1024">
</div>
<button type="submit" class="btn btn-primary btn-sm">Set password</button>
</form>
</div>
<!-- Delete -->
<div class="card" style="border:1px solid #7f1d1d">
<div class="text-sm font-semibold text-red-400 mb-2">Delete relay account</div>
<div class="text-xs text-gray-400 mb-3">Permanently removes this relay account and all its send-as patterns. Cannot be undone.</div>
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/delete"
onsubmit="return confirm('Delete relay account {{.Account.Username}}?')">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<button type="submit" class="btn btn-danger btn-sm">Delete account</button>
</form>
</div>
</div>
<!-- Right column -->
<div>
<!-- Account info -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Account info</div>
<div class="text-xs space-y-1.5 text-gray-400">
<div>Username: <span class="text-white" style="font-family:monospace">{{.Account.Username}}</span></div>
<div>Domain: <span class="text-white">{{.Domain.Name}}</span></div>
{{if .Account.DisplayName}}<div>Display name: <span class="text-white">{{.Account.DisplayName}}</span></div>{{end}}
{{if .Account.Description}}<div>Description: <span class="text-white">{{.Account.Description}}</span></div>{{end}}
<div>Created: <span class="text-white">{{shortTime .Account.CreatedAt}}</span></div>
<div class="text-gray-500 mt-2">This is a relay-only account. It has no mailbox or IMAP access.
Authenticate via SMTP using the username and password above.</div>
</div>
</div>
<!-- Send-as patterns -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Permitted sender addresses</div>
<div class="text-xs text-gray-400 mb-3">
Define which From addresses this relay account may use. Use
<span style="font-family:monospace;color:#93c5fd">*@{{.Domain.Name}}</span>
to allow any address at the domain, or specify exact addresses.
</div>
{{if .SendAs}}
<div style="margin-bottom:.75rem">
{{range .SendAs}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.Pattern}}</span>
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relayaccount/{{$.Account.ID}}/sendas/{{.ID}}/delete" style="margin:0">
<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>
</div>
{{end}}
</div>
{{else}}
<div class="text-xs text-gray-500 mb-3">No sender patterns configured. This account cannot send yet.</div>
{{end}}
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/sendas" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">Pattern (exact email or *@domain.com)</label>
<input type="text" name="pattern" required placeholder="sender@{{.Domain.Name}} or *@{{.Domain.Name}}" maxlength="255">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
<!-- IP whitelist -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Allowed source IPs</div>
<div class="text-xs text-gray-400 mb-3">
Connections from these IPs may send as any permitted sender address above
<span style="color:#d1d5db">without SMTP authentication</span>.
Accepts plain IPs or CIDR ranges.
</div>
{{if .IPs}}
<div style="margin-bottom:.75rem">
{{range .IPs}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<div>
<span style="font-family:monospace;font-size:.75rem;color:#6ee7b7">{{.CIDR}}</span>
{{if .Description}}<span style="font-size:.7rem;color:#6b7280;margin-left:.5rem">{{.Description}}</span>{{end}}
</div>
<form method="POST" action="/admin/domains/{{$.Domain.ID}}/relayaccount/{{$.Account.ID}}/ip/{{.ID}}/delete" style="margin:0">
<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>
</div>
{{end}}
</div>
{{else}}
<div class="text-xs text-gray-500 mb-3">No IP rules. Authentication always required.</div>
{{end}}
<form method="POST" action="/admin/domains/{{.Domain.ID}}/relayaccount/{{.Account.ID}}/ip" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1">
<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;flex:1">
<label style="font-size:.7rem">Description (optional)</label>
<input type="text" name="description" placeholder="App server" maxlength="255">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
</div>
</div>
{{end}}
+14 -43
View File
@@ -6,7 +6,6 @@
<h1 class="text-xl font-bold text-white">{{.U.Email}}</h1>
{{if .U.Enabled}}<span class="badge badge-green">active</span>{{else}}<span class="badge badge-red">disabled</span>{{end}}
{{if .U.Admin}}<span class="badge badge-yellow">admin</span>{{else if .U.DomainAdmin}}<span class="badge badge-gray">domain admin</span>{{end}}
{{if .U.IsRelay}}<span class="badge badge-blue">relay</span>{{end}}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
@@ -21,13 +20,11 @@
<label>Display name</label>
<input type="text" name="display_name" value="{{.U.DisplayName}}" maxlength="255">
</div>
{{if not .U.IsRelay}}
<div class="field">
<label>Quota (MB)</label>
<input type="number" name="quota_mb" value="{{mb .U.QuotaBytes}}" min="0" max="1048576">
</div>
{{end}}
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem;flex-wrap:wrap">
<div style="display:flex;gap:1.5rem;margin-bottom:.875rem">
<label style="display:flex;align-items:center;gap:.375rem;cursor:pointer;margin:0">
<input type="checkbox" name="enabled" value="1" {{if .U.Enabled}}checked{{end}} style="width:auto">
<span class="text-sm">Enabled</span>
@@ -45,7 +42,6 @@
</form>
</div>
<!-- Change password -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-3">Change password</div>
<form method="POST" action="/admin/users/{{.U.ID}}/password">
@@ -57,25 +53,6 @@
<button type="submit" class="btn btn-primary btn-sm">Set password</button>
</form>
</div>
<!-- Relay mode toggle -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Relay account mode</div>
<div class="text-xs text-gray-400 mb-3">
Relay accounts authenticate via SMTP only — no IMAP mailboxes, no webmail.
They can send email as their own address or any permitted send-as pattern below.
</div>
<form method="POST" action="/admin/users/{{.U.ID}}/relay/toggle">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
{{if .U.IsRelay}}
<input type="hidden" name="relay" value="0">
<button type="submit" class="btn btn-danger btn-sm">Disable relay mode</button>
{{else}}
<input type="hidden" name="relay" value="1">
<button type="submit" class="btn btn-primary btn-sm">Enable relay mode</button>
{{end}}
</form>
</div>
</div>
<!-- Right column -->
@@ -85,32 +62,27 @@
<div class="text-xs space-y-1.5 text-gray-400">
<div>Email: <span class="text-white">{{.U.Email}}</span></div>
<div>Domain: <span class="text-white">{{.U.DomainName}}</span></div>
{{if not .U.IsRelay}}
<div>Used: <span class="text-white">{{humanBytes .U.UsedBytes}}</span>
of <span class="text-white">{{if .U.QuotaBytes}}{{humanBytes .U.QuotaBytes}}{{else}}unlimited{{end}}</span></div>
{{end}}
<div>Created: <span class="text-white">{{shortTime .U.CreatedAt}}</span></div>
<div>Last login: <span class="text-white">{{if isZero .U.LastLogin}}never{{else}}{{shortTime .U.LastLogin}}{{end}}</span></div>
<div>MFA: <span class="text-white">{{if .U.MFAEnabled}}enabled{{else}}disabled{{end}}</span></div>
<div>Type: <span class="text-white">{{if .U.IsRelay}}relay (SMTP only){{else}}mailbox{{end}}</span></div>
</div>
</div>
{{if .U.IsRelay}}
<!-- Send-as patterns -->
<!-- Email aliases -->
<div class="card">
<div class="text-sm font-semibold text-gray-300 mb-2">Permitted sender addresses</div>
<div class="text-sm font-semibold text-gray-300 mb-2">Email aliases</div>
<div class="text-xs text-gray-400 mb-3">
This account can always send as its own address. Add patterns to allow additional
sender addresses. Use <span style="font-family:monospace;color:#93c5fd">*@domain.com</span>
to allow any address at a domain.
Alias addresses delivered to this account. The user may also send as any alias via SMTP.
</div>
{{if .SendAs}}
{{if .Aliases}}
<div style="margin-bottom:.75rem">
{{range .SendAs}}
{{range .Aliases}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:.375rem .5rem;background:#111827;border-radius:.25rem;margin-bottom:.375rem">
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.Pattern}}</span>
<form method="POST" action="/admin/users/{{$.U.ID}}/relay/{{.ID}}/delete" style="margin:0">
<span style="font-family:monospace;font-size:.75rem;color:#93c5fd">{{.AliasEmail}}</span>
<form method="POST" action="/admin/users/{{$.U.ID}}/alias/{{.ID}}/delete" style="margin:0"
onsubmit="return confirm('Remove alias {{.AliasEmail}}?')">
<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>
@@ -118,22 +90,21 @@
{{end}}
</div>
{{else}}
<div class="text-xs text-gray-500 mb-3">No additional patterns configured. Only own address allowed.</div>
<div class="text-xs text-gray-500 mb-3">No aliases configured.</div>
{{end}}
<form method="POST" action="/admin/users/{{.U.ID}}/relay/sendas" style="display:flex;gap:.5rem;align-items:flex-end">
<form method="POST" action="/admin/users/{{.U.ID}}/alias" style="display:flex;gap:.5rem;align-items:flex-end">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="field" style="margin:0;flex:1">
<label style="font-size:.7rem">Pattern (email or *@domain.com)</label>
<input type="text" name="pattern" required placeholder="newsletter@example.com or *@example.com" maxlength="255">
<label style="font-size:.7rem">Alias email address</label>
<input type="email" name="alias_email" required maxlength="320" placeholder="alias@{{.U.DomainName}}">
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
{{end}}
<div class="card" style="border:1px solid #7f1d1d">
<div class="text-sm font-semibold text-red-400 mb-2">Delete user</div>
<div class="text-xs text-gray-400 mb-3">Permanently deletes this account and all associated data. Cannot be undone.</div>
<div class="text-xs text-gray-400 mb-3">Permanently deletes the user account, all mailboxes, and all messages. Cannot be undone.</div>
<form method="POST" action="/admin/users/{{.U.ID}}/delete"
onsubmit="return confirm('Delete user {{.U.Email}} and all their data?')">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
-5
View File
@@ -40,10 +40,6 @@
<input type="checkbox" name="domain_admin" value="1" id="da" style="width:auto">
<label for="da" style="margin:0;cursor:pointer">Domain admin</label>
</div>
<div class="field" style="margin:0;display:flex;align-items:center;gap:.5rem;padding-top:1.25rem">
<input type="checkbox" name="relay" value="1" id="relay" style="width:auto">
<label for="relay" style="margin:0;cursor:pointer">Relay account (SMTP only)</label>
</div>
</div>
</form>
</div>
@@ -76,7 +72,6 @@
<td>
{{if .Admin}}<span class="badge badge-yellow">admin</span>
{{else if .DomainAdmin}}<span class="badge badge-gray">domain admin</span>
{{else if .IsRelay}}<span class="badge badge-blue">relay</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>