MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root { --sidebar-width: 280px; }
|
||||
@@ -65,6 +65,7 @@
|
||||
{{block "extra_css" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="main-container">
|
||||
{{template "sidebar_email.html" .}}
|
||||
|
||||
@@ -128,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
function updateTime() {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{{define "title"}}Blacklist - 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-shield-x me-2"></i>SMTP/IMAP Abuse Blacklist</h2>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Blacklisted IP Addresses</h5></div>
|
||||
<div class="card-body">
|
||||
{{if .entries}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead><tr><th>IP Address</th><th>Reason</th><th>Offense #</th><th>Type</th><th>Blacklisted</th><th>Expires</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .entries}}
|
||||
<tr>
|
||||
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
|
||||
<td><small class="text-muted">{{.Reason}}</small></td>
|
||||
<td>{{.OffenseCount}}</td>
|
||||
<td>{{if .Manual}}<span class="badge bg-secondary">Manual</span>{{else}}<span class="badge bg-warning text-dark">Auto</span>{{end}}</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .BlacklistedAt}}</small></td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .ExpiresAt}}</small></td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/whitelist" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" title="Whitelist this IP" data-confirm="Remove {{.IPAddress}} from the blacklist and exempt it from abuse detection?"><i class="bi bi-shield-check"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove entry" data-confirm="Remove the blacklist entry for {{.IPAddress}}?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-shield-check text-muted" style="font-size: 3rem;"></i>
|
||||
<h5 class="text-muted mt-3">No IPs currently blacklisted</h5>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Manually Blacklist an IP</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/pymta-manager/blacklist/add" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">IP Address</label>
|
||||
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Reason</label>
|
||||
<input type="text" name="reason" class="form-control" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Hours</label>
|
||||
<input type="number" name="hours" class="form-control" value="12" min="1" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-danger w-100"><i class="bi bi-shield-x me-1"></i>Blacklist</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Abuse-Detection Whitelist</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info small">
|
||||
IPs here are exempt from automatic blacklisting for failed SMTP/IMAP auth. This is separate
|
||||
from the <a href="/pymta-manager/ips">relay whitelist</a>, which authorizes unauthenticated sending for a domain.
|
||||
</div>
|
||||
{{if .whitelist}}
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-striped">
|
||||
<thead><tr><th>IP Address</th><th>Note</th><th>Added</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .whitelist}}
|
||||
<tr>
|
||||
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
|
||||
<td><small class="text-muted">{{.Note}}</small></td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
|
||||
<td>
|
||||
<form method="post" action="/pymta-manager/abuse-whitelist/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove {{.IPAddress}} from the abuse-detection whitelist?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="post" action="/pymta-manager/abuse-whitelist/add" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">IP Address</label>
|
||||
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Note</label>
|
||||
<input type="text" name="note" class="form-control" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-circle me-1"></i>Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "csrf_script"}}
|
||||
<script>
|
||||
// Auto-applies CSRF protection to every plain <form method=post> and every
|
||||
// same-origin mutating fetch() call on this page — no per-form or per-fetch-call
|
||||
// changes needed anywhere else in this codebase. See internal/webui/csrf.go for
|
||||
// the server-side check this token has to satisfy.
|
||||
window.__csrfToken = {{.csrf_token}};
|
||||
(function() {
|
||||
var token = window.__csrfToken;
|
||||
if (!token) return; // no session cookie yet (e.g. the login page itself)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return;
|
||||
if (form.querySelector('input[name="csrf_token"]')) return;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'csrf_token';
|
||||
input.value = token;
|
||||
form.appendChild(input);
|
||||
});
|
||||
});
|
||||
|
||||
var mutating = { POST: true, PUT: true, PATCH: true, DELETE: true };
|
||||
var originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function(url, options) {
|
||||
options = options || {};
|
||||
var method = (options.method || 'GET').toUpperCase();
|
||||
var isAbsolute = /^https?:\/\//i.test(url);
|
||||
var isSameOrigin = !isAbsolute || url.indexOf(window.location.origin) === 0;
|
||||
if (mutating[method] && isSameOrigin) {
|
||||
options = Object.assign({}, options);
|
||||
var headers = new Headers(options.headers || {});
|
||||
headers.set('X-CSRF-Token', token);
|
||||
options.headers = headers;
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -181,6 +181,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if dget . "is_global_admin"}}
|
||||
<div class="row">
|
||||
<div class="col-12 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-x me-2"></i>Attack Activity</h5>
|
||||
<a href="/pymta-manager/blacklist" class="btn btn-outline-light btn-sm">View Blacklist</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 col-md-2 mb-3 mb-md-0">
|
||||
<div class="fs-3 {{if gt (dget . "active_blacklist_count") 0}}text-danger{{else}}text-secondary{{end}}">{{dget . "active_blacklist_count"}}</div>
|
||||
<small class="text-muted">Active Blocks</small>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 mb-3 mb-md-0">
|
||||
<div class="fs-3 text-warning">{{dget . "failed_auth_24h"}}</div>
|
||||
<small class="text-muted">Failed Auth (24h)</small>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 mb-3 mb-md-0">
|
||||
<div class="fs-3 text-warning">{{dget . "failed_auth_7d"}}</div>
|
||||
<small class="text-muted">Failed Auth (7d)</small>
|
||||
</div>
|
||||
<div class="col-6 col-md-2 mb-3 mb-md-0">
|
||||
<div class="fs-3 text-danger">{{dget . "blacklist_events_24h"}}</div>
|
||||
<small class="text-muted">Blacklist Events (24h)</small>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<div class="fs-3 text-danger">{{dget . "blacklist_events_7d"}}</div>
|
||||
<small class="text-muted">Blacklist Events (7d)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<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">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap-icons/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%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-envelope-fill" style="font-size: 2.5rem;"></i>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<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">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap-icons/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%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if eq .filter_type "auth"}}
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/pymta-manager/logs?type=auth" class="btn {{if or (eq .auth_category "") (eq .auth_category "all")}}btn-secondary{{else}}btn-outline-secondary{{end}}">All</a>
|
||||
<a href="/pymta-manager/logs?type=auth&auth_category=admin" class="btn {{if eq .auth_category "admin"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Admin</a>
|
||||
<a href="/pymta-manager/logs?type=auth&auth_category=webmail" class="btn {{if eq .auth_category "webmail"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Webmail</a>
|
||||
<a href="/pymta-manager/logs?type=auth&auth_category=mailserver" class="btn {{if eq .auth_category "mailserver"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Mail Server</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
@@ -134,9 +145,9 @@
|
||||
{{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}}
|
||||
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&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}}
|
||||
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
@@ -14,44 +14,68 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
|
||||
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add">
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Match</label>
|
||||
<select class="form-select" name="match_type">
|
||||
<option value="all">ALL of the following (AND)</option>
|
||||
<option value="any">ANY of the following (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">If</label>
|
||||
<select class="form-select" name="condition_field">
|
||||
<option value="from">From</option>
|
||||
<option value="to">To</option>
|
||||
<option value="subject">Subject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" name="condition_op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
|
||||
|
||||
<div id="conditions_container"></div>
|
||||
<button type="button" id="add_condition" class="btn btn-outline-secondary btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="mark_as_spam">Mark as Spam</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="condition_row_template">
|
||||
<div class="row g-2 align-items-end mb-2 condition-row">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">If</label>
|
||||
<select class="form-select" name="condition_field">
|
||||
<option value="from">From</option>
|
||||
<option value="to">To</option>
|
||||
<option value="subject">Subject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" name="condition_op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,9 +90,10 @@
|
||||
{{range .rules}}
|
||||
<tr>
|
||||
<td>{{.Priority}}</td>
|
||||
<td><code>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
|
||||
<td><code>{{ruleSummary .}}</code></td>
|
||||
<td>
|
||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
|
||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||
{{else}}Mark as read{{end}}
|
||||
</td>
|
||||
@@ -100,5 +125,19 @@ document.getElementById('rule_action').addEventListener('change', function(e) {
|
||||
const valueInput = document.getElementById('rule_action_value');
|
||||
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
|
||||
});
|
||||
|
||||
function addConditionRow() {
|
||||
const tpl = document.getElementById('condition_row_template');
|
||||
const container = document.getElementById('conditions_container');
|
||||
const clone = document.importNode(tpl.content, true);
|
||||
clone.querySelector('.remove-condition').addEventListener('click', function() {
|
||||
if (container.children.length > 1) {
|
||||
this.closest('.condition-row').remove();
|
||||
}
|
||||
});
|
||||
container.appendChild(clone);
|
||||
}
|
||||
document.getElementById('add_condition').addEventListener('click', addConditionRow);
|
||||
addConditionRow();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up two-factor authentication - 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">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<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">
|
||||
@@ -55,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
|
||||
|
||||
@@ -86,6 +86,22 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if dget . "is_global_admin"}}
|
||||
<li class="nav-item mb-2">
|
||||
<h6 class="text-muted text-uppercase small mb-2 mt-3">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Security
|
||||
</h6>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<a href="/pymta-manager/blacklist" class="nav-link text-white {{if eq (dget . "active") "blacklist"}}active{{end}}">
|
||||
<i class="bi bi-shield-x me-2"></i>
|
||||
Blacklist
|
||||
<span class="badge bg-secondary ms-auto">{{dget . "blacklist_count"}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up authenticator app - 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">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.mailbox.Email}} - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
@@ -14,12 +14,19 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<form method="post" action="/webmail/logout" class="ms-auto">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -170,7 +177,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{{template "compose_widget" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const TOAST_AUTOHIDE_MS = 5000;
|
||||
function armToastAutoDismiss(toastEl, bsToast) {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
{{define "webmail_certs.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>Certs - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container pb-5">
|
||||
<h4 class="mb-4"><i class="bi bi-shield-lock me-2"></i>Certs</h4>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Two separate systems live here, each doing one job: <strong>S/MIME certificates sign</strong> outgoing mail (proves it came from you and wasn't altered) — <strong>PGP keys encrypt</strong> it (only the recipient can read it). They're different standards with different key formats; a message can use either, both, or neither. PGP private keys are protected by their own passphrase (never stored anywhere), so you'll be asked for it the first time you use one each session.
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3"><i class="bi bi-pen me-2"></i>S/MIME Certificates <small class="text-muted fs-6">— for signing</small></h5>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-person-badge me-2"></i>Your Certificates</h6></div>
|
||||
<div class="card-body">
|
||||
{{if .identities}}
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Expires</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .identities}}
|
||||
<tr>
|
||||
<td>{{.NotAfter.Format "2006-01-02"}}</td>
|
||||
<td class="d-flex gap-2">
|
||||
<a href="/webmail/smime/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download</a>
|
||||
<form method="post" action="/webmail/smime/identity/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this S/MIME certificate? Mail signed with it will no longer verify."><i class="bi bi-trash me-1"></i>Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have (.p12/.pfx).</p>
|
||||
{{end}}
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<h6>Generate New</h6>
|
||||
<form method="post" action="/webmail/smime/identity/generate" class="row g-2">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate Self-Signed Certificate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Import Existing (.p12 / .pfx)</h6>
|
||||
<form method="post" action="/webmail/smime/identity/import" enctype="multipart/form-data" class="row g-2">
|
||||
<div class="col-12">
|
||||
<input type="file" class="form-control form-control-sm" name="p12_file" accept=".p12,.pfx" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="password" class="form-control form-control-sm" name="p12_password" placeholder=".p12 export password (if any)">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-5">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>S/MIME Contact Certificates</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/webmail/smime/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Certificate (.pem/.crt/.cer)</label>
|
||||
<input type="file" class="form-control form-control-sm" name="cert_file" accept=".pem,.crt,.cer" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .contacts}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Email</th><th>Added</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .contacts}}
|
||||
<tr>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
||||
<td>
|
||||
<form method="post" action="/webmail/smime/contacts/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's certificate?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No contact certificates yet. They're also captured automatically when you open a validly signed email from someone new.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3"><i class="bi bi-lock me-2"></i>PGP Keys <small class="text-muted fs-6">— for encryption</small></h5>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>Your Keys</h6></div>
|
||||
<div class="card-body">
|
||||
{{if .pgp_identities}}
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Label</th><th>Fingerprint</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .pgp_identities}}
|
||||
<tr>
|
||||
<td>{{if .Label}}{{.Label}}{{else}}<span class="text-muted">(no label)</span>{{end}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{if index $.pgp_unlocked .ID}}<span class="badge bg-success">Unlocked this session</span>{{else}}<span class="badge bg-secondary">Locked</span>{{end}}</td>
|
||||
<td class="d-flex gap-2">
|
||||
<a href="/webmail/pgp/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download Public Key</a>
|
||||
<form method="post" action="/webmail/pgp/identity/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this PGP key? Mail encrypted to it will no longer decrypt."><i class="bi bi-trash me-1"></i>Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No PGP keys yet. Generate a new keypair, or import one you already have (an ASCII-armored .asc export from e.g. GnuPG).</p>
|
||||
{{end}}
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<h6>Generate New</h6>
|
||||
<form method="post" action="/webmail/pgp/identity/generate" class="row g-2">
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label (e.g. "Work") — optional, helps tell keys apart">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Choose a passphrase (min 8 chars)" required minlength="8">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="password" class="form-control form-control-sm" name="passphrase_confirm" placeholder="Confirm passphrase" required minlength="8">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate PGP Key</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Import Existing (.asc)</h6>
|
||||
<form method="post" action="/webmail/pgp/identity/import" enctype="multipart/form-data" class="row g-2">
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label — optional">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="The key's passphrase (its own, or a new one if it has none)" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>PGP Contact Keys</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/webmail/pgp/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Label</label>
|
||||
<input type="text" class="form-control form-control-sm" name="label" placeholder="optional">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Public Key (.asc)</label>
|
||||
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .pgp_contacts}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Email</th><th>Label</th><th>Fingerprint</th><th>Added</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .pgp_contacts}}
|
||||
<tr>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{.Label}}</td>
|
||||
<td><code class="small">{{.Fingerprint}}</code></td>
|
||||
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
||||
<td>
|
||||
<form method="post" action="/webmail/pgp/contacts/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's PGP key?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No contact keys yet. Ask a sender for their public key export, or grab it from a keyserver, and add it here before you can encrypt mail to them.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
function showConfirmation(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirmationModal');
|
||||
document.getElementById('confirmationModalBody').textContent = message;
|
||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||
const handleCancel = () => { resolve(false); cleanup(); };
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,408 @@
|
||||
{{define "webmail_compose.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>Compose - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --cw-bg: #1e1e1e; --cw-panel: #262626; --cw-border: #404040; --cw-text: #e0e0e0; --cw-muted: #9a9a9a; }
|
||||
html, body { height: 100%; margin: 0; overflow: hidden; }
|
||||
body { background-color: var(--cw-bg); color: var(--cw-text); font-size: .875rem; }
|
||||
|
||||
.compose-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.compose-actionbar { flex: 0 0 auto; display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid var(--cw-border); background-color: var(--cw-panel); flex-wrap: wrap; }
|
||||
.compose-actionbar .from-email { font-size: .8rem; color: var(--cw-muted); }
|
||||
.compose-actionbar select.from-select { max-width: 220px; }
|
||||
|
||||
.compose-fields { flex: 0 0 auto; padding: 0 .75rem; }
|
||||
.field-row { display: flex; align-items: center; border-bottom: 1px solid var(--cw-border); padding: .3rem 0; }
|
||||
.field-row .field-label { width: 42px; flex: 0 0 auto; color: var(--cw-muted); font-size: .8rem; }
|
||||
.field-row input.bare-input { flex: 1 1 auto; min-width: 0; border: 0; background: transparent; color: var(--cw-text); outline: none; font-size: .85rem; padding: .2rem 0; }
|
||||
.field-row input.bare-input::placeholder { color: var(--cw-muted); }
|
||||
.cc-bcc-toggle { flex: 0 0 auto; font-size: .75rem; color: #6ea8fe; cursor: pointer; margin-left: .5rem; white-space: nowrap; }
|
||||
.cc-bcc-toggle:hover { text-decoration: underline; }
|
||||
.subject-row input.bare-input { font-size: .95rem; padding: .4rem 0; }
|
||||
|
||||
.crypto-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .4rem 0; font-size: .78rem; color: var(--cw-muted); }
|
||||
.crypto-row .form-check { margin-bottom: 0; }
|
||||
.crypto-row .form-check-label { font-size: .8rem; }
|
||||
|
||||
.editor-wrap { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; padding: 0 .75rem .5rem; }
|
||||
.editor-wrap.drag-over { outline: 2px dashed #6ea8fe; outline-offset: -4px; }
|
||||
#editor { flex: 1 1 auto; min-height: 0; background-color: #fff; color: #000; }
|
||||
.ql-toolbar.ql-snow { flex: 0 0 auto; background-color: #333; border-color: var(--cw-border); border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
|
||||
.ql-container.ql-snow { border-color: var(--cw-border); border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
|
||||
|
||||
/* Quill's default snow-theme icons are near-black (#444) — invisible on a dark
|
||||
toolbar. Light stroke/fill + light picker text restores contrast. */
|
||||
.ql-snow .ql-stroke { stroke: #c8c8c8; }
|
||||
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
|
||||
.ql-snow .ql-picker { color: #c8c8c8; }
|
||||
.ql-snow .ql-picker-label { color: #c8c8c8; border-color: transparent; }
|
||||
.ql-snow .ql-picker-options { background-color: var(--cw-panel); border-color: var(--cw-border); }
|
||||
.ql-snow .ql-picker-item { color: #c8c8c8; }
|
||||
.ql-toolbar.ql-snow button:hover, .ql-toolbar.ql-snow button.ql-active,
|
||||
.ql-toolbar.ql-snow .ql-picker-label:hover, .ql-toolbar.ql-snow .ql-picker-label.ql-active,
|
||||
.ql-toolbar.ql-snow .ql-picker-item:hover { color: #fff; }
|
||||
.ql-toolbar.ql-snow button:hover .ql-stroke, .ql-toolbar.ql-snow button.ql-active .ql-stroke,
|
||||
.ql-toolbar.ql-snow button:hover .ql-fill, .ql-toolbar.ql-snow button.ql-active .ql-fill { stroke: #fff; }
|
||||
.ql-snow .ql-tooltip { background-color: var(--cw-panel); color: var(--cw-text); border-color: var(--cw-border); box-shadow: 0 2px 8px rgba(0,0,0,.4); }
|
||||
.ql-snow .ql-tooltip input[type="text"] { background-color: var(--cw-bg); color: var(--cw-text); border-color: var(--cw-border); }
|
||||
|
||||
.attachment-list:not(:empty) { padding: .4rem 0; display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<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>
|
||||
|
||||
<form id="composeForm" method="POST" action="/webmail/mail/compose" enctype="multipart/form-data" class="compose-shell">
|
||||
<input type="hidden" name="in_reply_to" value="{{.in_reply_to}}">
|
||||
<input type="hidden" name="draft_id" value="{{.draft_id}}">
|
||||
|
||||
<div class="compose-actionbar">
|
||||
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-send me-1"></i>Send</button>
|
||||
<button type="submit" formaction="/webmail/mail/save-draft" formnovalidate class="btn btn-outline-light btn-sm"><i class="bi bi-save2 me-1"></i>Save</button>
|
||||
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
|
||||
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
|
||||
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
{{if .send_as_options}}
|
||||
<select class="form-select form-select-sm from-select" name="from">
|
||||
<option value="{{.mailbox.Email}}">{{.mailbox.Email}}</option>
|
||||
{{range .send_as_options}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<span class="from-email">{{.mailbox.Email}}</span>
|
||||
{{end}}
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-sm btn-outline-light" title="Close"><i class="bi bi-x-lg"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compose-fields">
|
||||
<div class="field-row">
|
||||
<label class="field-label">To</label>
|
||||
<input type="text" class="bare-input recipient-input" name="to" value="{{.to}}" placeholder="recipient@example.com, another@example.com" list="recipientSuggestions" autocomplete="off" required>
|
||||
<span class="cc-bcc-toggle" id="showCc">Cc</span>
|
||||
<span class="cc-bcc-toggle" id="showBcc">Bcc</span>
|
||||
</div>
|
||||
<div class="field-row" id="ccRow" style="display: none;">
|
||||
<label class="field-label">Cc</label>
|
||||
<input type="text" class="bare-input recipient-input" name="cc" value="{{.cc}}" list="recipientSuggestions" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-row" id="bccRow" style="display: none;">
|
||||
<label class="field-label">Bcc</label>
|
||||
<input type="text" class="bare-input recipient-input" name="bcc" value="{{.bcc}}" list="recipientSuggestions" autocomplete="off">
|
||||
</div>
|
||||
<datalist id="recipientSuggestions"></datalist>
|
||||
<div class="field-row subject-row">
|
||||
<input type="text" class="bare-input" name="subject" value="{{.subject}}" placeholder="Add a subject">
|
||||
</div>
|
||||
<div class="crypto-row">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="smime_sign" value="1" id="smime_sign">
|
||||
<label class="form-check-label" for="smime_sign"><i class="bi bi-pen me-1"></i>Sign (S/MIME)</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="pgp_encrypt" value="1" id="pgp_encrypt">
|
||||
<label class="form-check-label" for="pgp_encrypt"><i class="bi bi-lock me-1"></i>Encrypt (PGP)</label>
|
||||
</div>
|
||||
{{if gt (len .smime_identities) 1}}
|
||||
<div id="smime_identity_row" style="display: none;">
|
||||
<select class="form-select form-select-sm" name="smime_identity_id">
|
||||
{{range .smime_identities}}<option value="{{.ID}}">Certificate expiring {{.NotAfter.Format "2006-01-02"}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{end}}
|
||||
<div id="pgp_recipient_row" style="display: none;">
|
||||
{{if .pgp_contacts}}
|
||||
<select class="form-select form-select-sm" name="pgp_recipient_id" multiple size="3" style="min-width: 220px;">
|
||||
{{range .pgp_contacts}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}{{.Email}}{{end}} — {{.Fingerprint}}</option>{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<span>No PGP contacts yet — add one on the Certs page.</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<a href="/webmail/certs" class="ms-auto text-muted">Manage certificates</a>
|
||||
</div>
|
||||
<div id="attachment_list" class="attachment-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="editor-wrap" id="editorWrap">
|
||||
<div id="editor"></div>
|
||||
<textarea name="body_html" style="display: none;"></textarea>
|
||||
</div>
|
||||
<div id="body_html_seed" style="display: none;">{{.body_html}}</div>
|
||||
</form>
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
|
||||
// Quill's default clipboard module already turns a pasted screenshot into an
|
||||
// embedded base64 <img> — that's what makes "paste a screenshot like Outlook"
|
||||
// work with no extra code here.
|
||||
var quill = new Quill('#editor', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ size: ['small', false, 'large', 'huge'] }],
|
||||
['link', 'image'],
|
||||
['clean'],
|
||||
],
|
||||
},
|
||||
});
|
||||
(function() {
|
||||
const seed = document.getElementById('body_html_seed');
|
||||
if (seed && seed.innerHTML.trim()) {
|
||||
quill.root.innerHTML = seed.innerHTML;
|
||||
// Reply/forward seeds start with an empty line above the quoted
|
||||
// original (see composeCursorHome, webmail_compose.go) — put the
|
||||
// cursor there so typing starts above the quote, not inside or after it.
|
||||
quill.setSelection(0, 0);
|
||||
quill.focus();
|
||||
}
|
||||
})();
|
||||
|
||||
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
|
||||
// Cc) — clicking a toggle reveals its row and focuses the field, same as
|
||||
// clicking "Cc"/"Bcc" in a real mail client.
|
||||
(function() {
|
||||
function wireToggle(toggleId, rowId, fieldName) {
|
||||
const toggle = document.getElementById(toggleId);
|
||||
const row = document.getElementById(rowId);
|
||||
const field = row.querySelector('[name="' + fieldName + '"]');
|
||||
if (field.value.trim()) {
|
||||
row.style.display = 'flex';
|
||||
toggle.style.display = 'none';
|
||||
} else {
|
||||
toggle.addEventListener('click', function() {
|
||||
row.style.display = 'flex';
|
||||
toggle.style.display = 'none';
|
||||
field.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
wireToggle('showCc', 'ccRow', 'cc');
|
||||
wireToggle('showBcc', 'bccRow', 'bcc');
|
||||
})();
|
||||
|
||||
// Attachments accumulate across multiple picks/drops instead of the native
|
||||
// file input's default "replace the whole selection" behavior — a DataTransfer
|
||||
// is the only way to build an editable FileList, so it's kept as the source of
|
||||
// truth and re-assigned onto the real (hidden) input before every render.
|
||||
// Drag-and-drop works anywhere over the message area, matching a real client —
|
||||
// there's no dedicated dropzone box taking up space when empty.
|
||||
(function() {
|
||||
const form = document.getElementById('composeForm');
|
||||
const dropTarget = document.getElementById('editorWrap');
|
||||
const attachBtn = document.getElementById('attachBtn');
|
||||
const input = document.getElementById('attachment_input');
|
||||
const list = document.getElementById('attachment_list');
|
||||
let staged = new DataTransfer();
|
||||
|
||||
function render() {
|
||||
input.files = staged.files;
|
||||
list.innerHTML = '';
|
||||
Array.from(staged.files).forEach(function(file, i) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'badge text-bg-secondary d-flex align-items-center gap-1 py-2 px-2';
|
||||
chip.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'btn-close btn-close-white ms-1';
|
||||
remove.style.fontSize = '0.65rem';
|
||||
remove.setAttribute('aria-label', 'Remove');
|
||||
remove.addEventListener('click', function() { removeFile(i); });
|
||||
chip.appendChild(remove);
|
||||
list.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
Array.from(fileList).forEach(function(file) { staged.items.add(file); });
|
||||
render();
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
const next = new DataTransfer();
|
||||
Array.from(staged.files).forEach(function(file, i) { if (i !== index) next.items.add(file); });
|
||||
staged = next;
|
||||
render();
|
||||
}
|
||||
|
||||
attachBtn.addEventListener('click', function() { input.click(); });
|
||||
input.addEventListener('change', function() { addFiles(input.files); });
|
||||
form.addEventListener('dragover', function(e) { e.preventDefault(); dropTarget.classList.add('drag-over'); });
|
||||
form.addEventListener('dragleave', function(e) { if (e.target === form) dropTarget.classList.remove('drag-over'); });
|
||||
form.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dropTarget.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
window.__composeAttachmentCount = function() { return staged.files.length; };
|
||||
})();
|
||||
|
||||
// The identity picker (when there's more than one S/MIME certificate) only
|
||||
// matters for signing — encrypting alone never touches it.
|
||||
(function() {
|
||||
const signCheckbox = document.getElementById('smime_sign');
|
||||
const identityRow = document.getElementById('smime_identity_row');
|
||||
if (!identityRow) return;
|
||||
function update() { identityRow.style.display = signCheckbox.checked ? '' : 'none'; }
|
||||
signCheckbox.addEventListener('change', update);
|
||||
update();
|
||||
})();
|
||||
|
||||
// The recipient-key picker only matters for encrypting.
|
||||
(function() {
|
||||
const encryptCheckbox = document.getElementById('pgp_encrypt');
|
||||
const recipientRow = document.getElementById('pgp_recipient_row');
|
||||
function update() { recipientRow.style.display = encryptCheckbox.checked ? '' : 'none'; }
|
||||
encryptCheckbox.addEventListener('change', update);
|
||||
update();
|
||||
})();
|
||||
|
||||
// Recipient autocomplete: suggests addresses this mailbox has exchanged mail
|
||||
// with, matching the fragment being typed after the last comma (a
|
||||
// To/Cc/Bcc field holds a comma-separated address list, so only the
|
||||
// in-progress fragment should be matched/replaced, not the whole value).
|
||||
(function() {
|
||||
const datalist = document.getElementById('recipientSuggestions');
|
||||
let debounceTimer = null;
|
||||
document.querySelectorAll('.recipient-input').forEach(function(input) {
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
const value = input.value;
|
||||
const splitAt = value.lastIndexOf(',') + 1;
|
||||
const fragment = value.slice(splitAt).trim();
|
||||
if (!fragment) { datalist.innerHTML = ''; return; }
|
||||
debounceTimer = setTimeout(function() {
|
||||
fetch('/webmail/mail/recipients?q=' + encodeURIComponent(fragment))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(suggestions) {
|
||||
datalist.innerHTML = '';
|
||||
const prefix = value.slice(0, splitAt);
|
||||
(suggestions || []).forEach(function(addr) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = (prefix ? prefix + ' ' : '') + addr;
|
||||
datalist.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Draft autosave: periodically saves in the background if anything's
|
||||
// changed since the last autosave, reusing the existing Save Draft endpoint
|
||||
// exactly as a manual click would submit it — no backend change needed. The
|
||||
// browser follows the redirect to /webmail/mail/compose?draft=ID&folder=Drafts,
|
||||
// and that URL's draft param becomes the new draft_id so the next autosave (or
|
||||
// manual Send) updates the same draft instead of creating a new one.
|
||||
// Deliberately text-only: attachments aren't re-uploaded on every tick (a
|
||||
// real Save or Send still includes them) to avoid repeatedly re-sending
|
||||
// large file data in the background.
|
||||
(function() {
|
||||
const form = document.getElementById('composeForm');
|
||||
const draftIdInput = document.querySelector('[name="draft_id"]');
|
||||
const status = document.getElementById('autosaveStatus');
|
||||
let lastSaved = null;
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify({
|
||||
to: document.querySelector('[name="to"]').value,
|
||||
cc: document.querySelector('[name="cc"]').value,
|
||||
bcc: document.querySelector('[name="bcc"]').value,
|
||||
subject: document.querySelector('[name="subject"]').value,
|
||||
body: quill.root.innerHTML,
|
||||
});
|
||||
}
|
||||
|
||||
async function autosave() {
|
||||
const current = snapshot();
|
||||
if (current === lastSaved) return;
|
||||
const subject = document.querySelector('[name="subject"]').value.trim();
|
||||
const to = document.querySelector('[name="to"]').value.trim();
|
||||
if (!to && !subject && !quill.getText().trim()) return; // nothing worth saving yet
|
||||
|
||||
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
|
||||
const formData = new FormData(form);
|
||||
formData.delete('attachments');
|
||||
try {
|
||||
const resp = await fetch('/webmail/mail/save-draft', { method: 'POST', body: formData });
|
||||
if (resp.ok) {
|
||||
const draftId = new URL(resp.url).searchParams.get('draft');
|
||||
if (draftId) draftIdInput.value = draftId;
|
||||
lastSaved = current;
|
||||
if (status) {
|
||||
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
||||
status.style.display = '';
|
||||
}
|
||||
}
|
||||
} catch (e) { /* offline or a transient error — retried next interval */ }
|
||||
}
|
||||
|
||||
setInterval(autosave, 30000);
|
||||
})();
|
||||
|
||||
// Enter in a single-line field (To/Cc/Bcc/Subject) implicitly submits the
|
||||
// form — that's how a stray keypress used to send a blank email.
|
||||
['to', 'cc', 'bcc', 'subject'].forEach(function(name) {
|
||||
const el = document.querySelector('[name="' + name + '"]');
|
||||
if (el) {
|
||||
el.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('composeForm').addEventListener('submit', function(e) {
|
||||
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
|
||||
// Save (formaction=save-draft) deliberately skips the subject/body-required
|
||||
// guard below — a draft can be incomplete by definition.
|
||||
const isDraftSave = e.submitter && e.submitter.getAttribute('formaction') === '/webmail/mail/save-draft';
|
||||
if (isDraftSave) return;
|
||||
const subject = document.querySelector('[name="subject"]').value.trim();
|
||||
const body = quill.getText().trim();
|
||||
const hasAttachments = window.__composeAttachmentCount && window.__composeAttachmentCount() > 0;
|
||||
if (!subject) {
|
||||
e.preventDefault();
|
||||
alert('Please add a subject before sending.');
|
||||
return;
|
||||
}
|
||||
if (!body && !hasAttachments) {
|
||||
e.preventDefault();
|
||||
alert('Please write a message or add an attachment before sending.');
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,122 @@
|
||||
{{define "compose_widget"}}
|
||||
<div id="composePopup" class="card shadow" style="display: none; position: fixed; z-index: 1080; min-width: 320px; min-height: 200px; resize: none; overflow: hidden; background-color: #2d2d2d; border: 1px solid #404040;">
|
||||
<div id="composePopupHandle" class="card-header d-flex justify-content-between align-items-center py-1" style="cursor: move; user-select: none;">
|
||||
<span class="small"><i class="bi bi-pencil-square me-1"></i>Compose</span>
|
||||
<div>
|
||||
<button type="button" id="composePopupClose" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Close"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="composePopupFrame" style="border: 0; width: 100%; flex: 1 1 auto;"></iframe>
|
||||
<div id="composePopupResize" style="position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;"></div>
|
||||
</div>
|
||||
<script>
|
||||
// A floating window over the existing /webmail/mail/compose page (loaded as-is
|
||||
// in an iframe) — Outlook-style compose without rewriting compose itself as a
|
||||
// modal. Position/size persist in localStorage so it reopens where it was left.
|
||||
(function() {
|
||||
const POS_KEY = 'webmail_compose_popup_pos';
|
||||
const popup = document.getElementById('composePopup');
|
||||
const handle = document.getElementById('composePopupHandle');
|
||||
const frame = document.getElementById('composePopupFrame');
|
||||
const resizeHandle = document.getElementById('composePopupResize');
|
||||
const closeBtn = document.getElementById('composePopupClose');
|
||||
|
||||
function defaultRect() {
|
||||
const w = Math.min(720, window.innerWidth - 40);
|
||||
const h = Math.min(600, window.innerHeight - 40);
|
||||
return { top: Math.max(20, (window.innerHeight - h) / 2), left: Math.max(20, (window.innerWidth - w) / 2), width: w, height: h };
|
||||
}
|
||||
|
||||
function clampRect(r) {
|
||||
const width = Math.max(320, Math.min(r.width, window.innerWidth - 20));
|
||||
const height = Math.max(200, Math.min(r.height, window.innerHeight - 20));
|
||||
const left = Math.max(0, Math.min(r.left, window.innerWidth - width));
|
||||
const top = Math.max(0, Math.min(r.top, window.innerHeight - height));
|
||||
return { top, left, width, height };
|
||||
}
|
||||
|
||||
function loadRect() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(POS_KEY));
|
||||
if (saved && typeof saved.top === 'number') return clampRect(saved);
|
||||
} catch (e) { /* fall through to default */ }
|
||||
return defaultRect();
|
||||
}
|
||||
|
||||
function saveRect(r) {
|
||||
localStorage.setItem(POS_KEY, JSON.stringify(r));
|
||||
}
|
||||
|
||||
function applyRect(r) {
|
||||
popup.style.top = r.top + 'px';
|
||||
popup.style.left = r.left + 'px';
|
||||
popup.style.width = r.width + 'px';
|
||||
popup.style.height = r.height + 'px';
|
||||
}
|
||||
|
||||
function currentRect() {
|
||||
return {
|
||||
top: parseFloat(popup.style.top) || 0,
|
||||
left: parseFloat(popup.style.left) || 0,
|
||||
width: parseFloat(popup.style.width) || 0,
|
||||
height: parseFloat(popup.style.height) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
window.openCompose = function(url) {
|
||||
applyRect(loadRect());
|
||||
popup.style.display = 'flex';
|
||||
popup.style.flexDirection = 'column';
|
||||
frame.src = url;
|
||||
};
|
||||
|
||||
function closePopup() {
|
||||
popup.style.display = 'none';
|
||||
frame.src = 'about:blank';
|
||||
}
|
||||
closeBtn.addEventListener('click', closePopup);
|
||||
|
||||
// The compose form navigates the iframe on success (send/save-draft both
|
||||
// redirect away from /mail/compose) — treat that as "done": close the popup
|
||||
// and refresh the page underneath so the new Sent/Drafts entry shows up.
|
||||
frame.addEventListener('load', function() {
|
||||
let path;
|
||||
try { path = frame.contentWindow.location.pathname; } catch (e) { return; }
|
||||
if (frame.src === 'about:blank' || path.indexOf('/webmail/mail/compose') !== -1) return;
|
||||
closePopup();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
let dragOffset = null;
|
||||
handle.addEventListener('mousedown', function(e) {
|
||||
if (e.target.closest('button')) return;
|
||||
const r = popup.getBoundingClientRect();
|
||||
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
let resizing = false;
|
||||
resizeHandle.addEventListener('mousedown', function(e) {
|
||||
resizing = true;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (dragOffset) {
|
||||
const r = clampRect({ top: e.clientY - dragOffset.y, left: e.clientX - dragOffset.x, width: currentRect().width, height: currentRect().height });
|
||||
applyRect(r);
|
||||
} else if (resizing) {
|
||||
const r = popup.getBoundingClientRect();
|
||||
const rect = clampRect({ top: r.top, left: r.left, width: e.clientX - r.left, height: e.clientY - r.top });
|
||||
applyRect(rect);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (dragOffset || resizing) saveRect(currentRect());
|
||||
dragOffset = null;
|
||||
resizing = false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,282 @@
|
||||
{{define "webmail_folder.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>{{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
|
||||
.msg-unread { font-weight: 600; }
|
||||
.msg-row { cursor: grab; }
|
||||
.msg-row.dragging { opacity: 0.4; }
|
||||
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
|
||||
.folder-unread-badge { font-size: .7rem; }
|
||||
.msg-row-older { display: none; }
|
||||
.msg-group-toggle { cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container-fluid pb-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-2 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<form method="get" action="/webmail/mail/search" class="mb-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
|
||||
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="list-group list-group-flush">
|
||||
{{$active := .active_folder}}
|
||||
{{$unread := .unread_counts}}
|
||||
{{range .folders}}
|
||||
<div class="d-flex align-items-center folder-row">
|
||||
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
|
||||
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
|
||||
{{$n := index $unread .}}
|
||||
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}}
|
||||
</a>
|
||||
{{if not (isStandardFolder .)}}
|
||||
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder "{{.}}"? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
|
||||
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-10 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for “{{.search_query}}”
|
||||
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}}
|
||||
</h5>
|
||||
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{{if .messages}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}}
|
||||
<th>Subject</th>
|
||||
{{if .search_query}}<th>Folder</th>{{end}}
|
||||
<th>Date</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$folders := .folders}}
|
||||
{{$showFolderCol := .search_query}}
|
||||
{{range .messages}}
|
||||
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
|
||||
{{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
|
||||
<tr class="{{if .Unread}}msg-unread{{end}} msg-row{{if .Collapsed}} msg-row-older{{end}}" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}">
|
||||
<td><a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}}</a></td>
|
||||
<td>
|
||||
<a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if .CachedSubject}}{{.CachedSubject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</a>
|
||||
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
|
||||
</td>
|
||||
{{if $showFolderCol}}<td><small class="text-muted">{{.Folder}}</small></td>{{end}}
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</small></td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/move" class="d-inline-flex">
|
||||
<select name="target_folder" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
|
||||
<option value="">Move to…</option>
|
||||
{{$rowFolder := .Folder}}
|
||||
{{range $folders}}{{if ne . $rowFolder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
||||
</select>
|
||||
</form>
|
||||
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .Folder "Trash"}}Delete permanently{{else}}Move to Trash{{end}}" data-confirm="{{if eq .Folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if or .has_prev .has_next}}
|
||||
<div class="d-flex justify-content-between p-3">
|
||||
{{if .has_prev}}<a href="?page={{sub .page 1}}" class="btn btn-outline-secondary btn-sm">« Newer</a>{{else}}<span></span>{{end}}
|
||||
{{if .has_next}}<a href="?page={{add .page 1}}" class="btn btn-outline-secondary btn-sm">Older »</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||
<h5 class="text-muted mt-3">No messages in {{.active_folder}}</h5>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
{{template "webmail_shortcuts" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
function showConfirmation(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirmationModal');
|
||||
document.getElementById('confirmationModalBody').textContent = message;
|
||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||
const handleCancel = () => { resolve(false); cleanup(); };
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Drag a message row onto a folder in the sidebar to move it there — a
|
||||
// shortcut for the same "Move to..." dropdown every row already has. Each
|
||||
// row carries its OWN folder (data-folder) rather than assuming the page's
|
||||
// active folder, since a search result can span multiple folders.
|
||||
(function() {
|
||||
let draggedUID = null;
|
||||
let draggedFolder = null;
|
||||
|
||||
document.querySelectorAll('.msg-row').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function() {
|
||||
draggedUID = row.dataset.uid;
|
||||
draggedFolder = row.dataset.folder;
|
||||
row.classList.add('dragging');
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.folder-link').forEach(function(link) {
|
||||
link.addEventListener('dragover', function(e) {
|
||||
if (!draggedUID || link.dataset.folder === draggedFolder) return;
|
||||
e.preventDefault();
|
||||
link.classList.add('drop-hover');
|
||||
});
|
||||
link.addEventListener('dragleave', function() {
|
||||
link.classList.remove('drop-hover');
|
||||
});
|
||||
link.addEventListener('drop', async function(e) {
|
||||
e.preventDefault();
|
||||
link.classList.remove('drop-hover');
|
||||
const targetFolder = link.dataset.folder;
|
||||
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
|
||||
const body = new URLSearchParams();
|
||||
body.set('target_folder', targetFolder);
|
||||
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// "+N more" toggle for a collapsed same-subject run — reveals every
|
||||
// immediately-following row marked as part of that group, self-terminating
|
||||
// at the first row that isn't (no need to track the count client-side).
|
||||
document.querySelectorAll('[data-group-toggle]').forEach(function(badge) {
|
||||
badge.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let sib = badge.closest('tr').nextElementSibling;
|
||||
while (sib && sib.classList.contains('msg-row-older')) {
|
||||
sib.style.display = '';
|
||||
sib = sib.nextElementSibling;
|
||||
}
|
||||
badge.style.display = 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/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%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify it's you - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/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%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container login-card">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
{{define "webmail_message.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>{{.parsed.Header.Subject}} - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container pb-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a href="/webmail/mail/{{.active_folder}}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to {{.active_folder}}</a>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" id="replyBtn" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply me-1"></i>Reply</button>
|
||||
<button type="button" id="replyAllBtn" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply-all me-1"></i>Reply All</button>
|
||||
<button type="button" id="forwardBtn" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-arrow-right me-1"></i>Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-2">{{if .parsed.Header.Subject}}{{.parsed.Header.Subject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</h5>
|
||||
{{if or .smime.Signed .smime.Encrypted}}
|
||||
<div class="mb-2">
|
||||
{{if .smime.Encrypted}}
|
||||
{{if .smime.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>Encrypted & decrypted</span>
|
||||
{{else}}<span class="badge bg-danger" title="{{.smime.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>Encrypted — could not decrypt</span>{{end}}
|
||||
{{end}}
|
||||
{{if .smime.Signed}}
|
||||
{{if .smime.SignatureOK}}<span class="badge bg-success" title="{{.smime.SignerEmail}}"><i class="bi bi-patch-check-fill me-1"></i>Signature verified{{if .smime.SignerEmail}} ({{.smime.SignerEmail}}){{end}}</span>
|
||||
{{else}}<span class="badge bg-danger" title="{{.smime.SignatureErr}}"><i class="bi bi-exclamation-triangle-fill me-1"></i>Signature invalid</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .pgp.Encrypted}}
|
||||
<div class="mb-2">
|
||||
{{if .pgp.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>PGP encrypted & decrypted</span>
|
||||
{{else if .pgp.NeedsUnlock}}<span class="badge bg-warning text-dark"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — enter your passphrase to decrypt</span>
|
||||
{{else}}<span class="badge bg-danger" title="{{.pgp.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — could not decrypt</span>{{end}}
|
||||
</div>
|
||||
{{if .pgp.NeedsUnlock}}
|
||||
<form method="post" action="/webmail/pgp/unlock" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="next" value="{{.message_url}}">
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" name="identity_id">
|
||||
{{range .pgp.Identities}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}Key{{end}} ({{.Fingerprint}})</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Passphrase" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-warning btn-sm">Unlock & Decrypt</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<div class="small text-muted">
|
||||
<div><strong>From:</strong> {{.parsed.Header.From}}</div>
|
||||
<div><strong>To:</strong> {{.parsed.Header.To}}</div>
|
||||
{{if .parsed.Header.Cc}}<div><strong>Cc:</strong> {{.parsed.Header.Cc}}</div>{{end}}
|
||||
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .html_body}}
|
||||
<div class="msg-body-html">{{.html_body}}</div>
|
||||
{{else if .parsed.TextBody}}
|
||||
<div class="msg-body-text">{{.parsed.TextBody}}</div>
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">(empty message body)</p>
|
||||
{{end}}
|
||||
|
||||
{{if .parsed.Attachments}}
|
||||
<hr>
|
||||
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
|
||||
<div class="list-group">
|
||||
{{$folder := .active_folder}}
|
||||
{{$uid := .uid}}
|
||||
{{range $i, $att := .parsed.Attachments}}
|
||||
<a href="/webmail/mail/{{$folder}}/{{$uid}}/attachment/{{$i}}" class="list-group-item list-group-item-action bg-transparent text-white d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-file-earmark me-2"></i>{{$att.Filename}}</span>
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-2">
|
||||
<select name="target_folder" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value="">Move to…</option>
|
||||
{{$folder := .active_folder}}
|
||||
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">Move</button>
|
||||
</form>
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
{{template "webmail_shortcuts" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
function showConfirmation(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirmationModal');
|
||||
document.getElementById('confirmationModalBody').textContent = message;
|
||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||
const handleCancel = () => { resolve(false); cleanup(); };
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up two-factor authentication - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
|
||||
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
|
||||
@@ -14,6 +14,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<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">
|
||||
@@ -55,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
{{define "webmail_rules.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>Filter Rules - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container pb-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0"><i class="bi bi-funnel me-2"></i>Filter Rules</h4>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Rules run in priority order (lowest first) at delivery time; the first match wins. "Move to folder" delivers into a separate folder instead of INBOX — check <a href="/webmail/mail/INBOX" class="alert-link">Mail</a> once something's actually landed there.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Rule</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/webmail/rules/add">
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Priority</label>
|
||||
<input type="number" class="form-control" name="priority" value="0" style="width: 90px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Match</label>
|
||||
<select class="form-select" name="match_type">
|
||||
<option value="all">ALL of the following (AND)</option>
|
||||
<option value="any">ANY of the following (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="conditions_container"></div>
|
||||
<button type="button" id="add_condition" class="btn btn-outline-light btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="mark_as_spam">Mark as Spam</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="action_value" id="rule_action_value" placeholder="folder name">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-funnel me-2"></i>Add Rule</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="condition_row_template">
|
||||
<div class="row g-2 align-items-end mb-2 condition-row">
|
||||
<div class="col-auto">
|
||||
<label class="form-label">If</label>
|
||||
<select class="form-select" name="condition_field">
|
||||
<option value="from">From</option>
|
||||
<option value="to">To</option>
|
||||
<option value="subject">Subject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" name="condition_op">
|
||||
<option value="contains">contains</option>
|
||||
<option value="equals">equals</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" name="condition_value" placeholder="value" required>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Rules</h5></div>
|
||||
<div class="card-body p-0">
|
||||
{{if .rules}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Priority</th><th>Condition</th><th>Action</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .rules}}
|
||||
<tr>
|
||||
<td>{{.Priority}}</td>
|
||||
<td><code>{{ruleSummary .}}</code></td>
|
||||
<td>
|
||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
|
||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||
{{else}}Mark as read{{end}}
|
||||
</td>
|
||||
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-secondary">Inactive</span>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/webmail/rules/{{.ID}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this rule?"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-funnel text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="text-muted mt-3">No rules yet</h4>
|
||||
<p class="text-muted">Add one above to automatically sort or act on incoming mail.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-question-circle me-2"></i>Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="confirmationModalBody">Are you sure you want to proceed?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmationModalConfirm">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
function showConfirmation(message) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirmationModal');
|
||||
document.getElementById('confirmationModalBody').textContent = message;
|
||||
const confirmButton = document.getElementById('confirmationModalConfirm');
|
||||
const handleConfirm = () => { resolve(true); bootstrap.Modal.getInstance(modal).hide(); cleanup(); };
|
||||
const handleCancel = () => { resolve(false); cleanup(); };
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
document.getElementById('rule_action').addEventListener('change', function(e) {
|
||||
const valueInput = document.getElementById('rule_action_value');
|
||||
valueInput.style.display = e.target.value === 'move_to_folder' ? '' : 'none';
|
||||
});
|
||||
|
||||
function addConditionRow() {
|
||||
const tpl = document.getElementById('condition_row_template');
|
||||
const container = document.getElementById('conditions_container');
|
||||
const clone = document.importNode(tpl.content, true);
|
||||
clone.querySelector('.remove-condition').addEventListener('click', function() {
|
||||
if (container.children.length > 1) {
|
||||
this.closest('.condition-row').remove();
|
||||
}
|
||||
});
|
||||
container.appendChild(clone);
|
||||
}
|
||||
document.getElementById('add_condition').addEventListener('click', addConditionRow);
|
||||
addConditionRow();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "webmail_shortcuts"}}
|
||||
<style>
|
||||
.msg-row-selected { outline: 2px solid #6ea8fe; outline-offset: -2px; background-color: rgba(110, 168, 254, 0.12); }
|
||||
</style>
|
||||
<script>
|
||||
// Keyboard shortcuts for the folder list (j/k/Enter/o/c//) and the single-message
|
||||
// view (c/r/a/f/#). Feature-detects which page it's on by which elements exist,
|
||||
// so one shared partial covers both without a page-specific flag.
|
||||
(function() {
|
||||
function isTypingTarget(el) {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('.msg-row:not(.msg-row-older)'));
|
||||
const searchInput = document.getElementById('mailSearchInput');
|
||||
|
||||
if (e.key === '/') {
|
||||
if (searchInput) { e.preventDefault(); searchInput.focus(); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'c' && typeof openCompose === 'function') {
|
||||
e.preventDefault();
|
||||
openCompose('/webmail/mail/compose');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Folder list: j/k select, Enter/o open, #/Delete trash the selected row.
|
||||
if (rows.length) {
|
||||
let idx = rows.findIndex(function(r) { return r.classList.contains('msg-row-selected'); });
|
||||
if (e.key === 'j' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (idx >= 0) rows[idx].classList.remove('msg-row-selected');
|
||||
idx = e.key === 'j' ? Math.min(idx + 1, rows.length - 1) : Math.max(idx - 1, 0);
|
||||
rows[idx].classList.add('msg-row-selected');
|
||||
rows[idx].scrollIntoView({ block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Enter' || e.key === 'o') && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const link = rows[idx].querySelector('a[href]');
|
||||
if (link) window.location = link.href;
|
||||
return;
|
||||
}
|
||||
if ((e.key === '#' || e.key === 'Delete') && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const deleteBtn = rows[idx].querySelector('form[action*="/delete"] button[type=submit]');
|
||||
if (deleteBtn) deleteBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single-message view: r/a/f reply/reply-all/forward, #/Delete trash.
|
||||
const replyBtn = document.getElementById('replyBtn');
|
||||
if (replyBtn) {
|
||||
if (e.key === 'r') { e.preventDefault(); replyBtn.click(); return; }
|
||||
if (e.key === 'a') { e.preventDefault(); document.getElementById('replyAllBtn').click(); return; }
|
||||
if (e.key === 'f') { e.preventDefault(); document.getElementById('forwardBtn').click(); return; }
|
||||
if (e.key === '#' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
const deleteBtn = document.querySelector('form[action*="/delete"] button[type=submit]');
|
||||
if (deleteBtn) deleteBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -5,14 +5,15 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set up authenticator app - Webmail</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
@@ -31,7 +32,7 @@
|
||||
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/webmail/" class="btn btn-secondary">Cancel</a>
|
||||
<a href="/webmail/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>
|
||||
|
||||
Reference in New Issue
Block a user