updated mailbox app password
This commit is contained in:
@@ -47,3 +47,44 @@ func popFlashes(w http.ResponseWriter, r *http.Request) []Flash {
|
||||
}
|
||||
return flashes
|
||||
}
|
||||
|
||||
// AppPasswordReveal carries a freshly-generated app password secret across the
|
||||
// create->redirect hop, kept out of the toast-driven Flash system so it can render
|
||||
// as its own centered modal (with a copy button) instead of a toast that's easy to
|
||||
// miss and can't be copied without retyping.
|
||||
type AppPasswordReveal struct {
|
||||
Label string `json:"l"`
|
||||
Secret string `json:"s"`
|
||||
}
|
||||
|
||||
const appPasswordRevealCookieName = "app_pw_reveal"
|
||||
|
||||
// setAppPasswordReveal mirrors setFlash but for the one-time secret reveal.
|
||||
func setAppPasswordReveal(w http.ResponseWriter, label, secret string) {
|
||||
encoded, _ := json.Marshal(AppPasswordReveal{Label: label, Secret: secret})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: appPasswordRevealCookieName,
|
||||
Value: base64.URLEncoding.EncodeToString(encoded),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// popAppPasswordReveal mirrors popFlashes but for the one-time secret reveal.
|
||||
func popAppPasswordReveal(w http.ResponseWriter, r *http.Request) *AppPasswordReveal {
|
||||
c, err := r.Cookie(appPasswordRevealCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: appPasswordRevealCookieName, Value: "", Path: "/", MaxAge: -1})
|
||||
raw, err := base64.URLEncoding.DecodeString(c.Value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var reveal AppPasswordReveal
|
||||
if err := json.Unmarshal(raw, &reveal); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &reveal
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
@@ -18,12 +20,50 @@ func (a *App) appPasswordsList(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading app passwords")
|
||||
}
|
||||
a.render(w, r, "mailbox_apppasswords.html", M{"active": "mailboxes", "mailbox": mailbox, "passwords": passwords})
|
||||
a.render(w, r, "mailbox_apppasswords.html", M{
|
||||
"active": "mailboxes", "mailbox": mailbox, "passwords": passwords,
|
||||
"reveal": popAppPasswordReveal(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
// appPasswordExpiry turns the create form's preset select (plus an optional custom
|
||||
// date) into an expiry timestamp. Returns (nil, nil) for "never expires", the default.
|
||||
func appPasswordExpiry(preset, customDate string, loc *time.Location) (*time.Time, error) {
|
||||
now := time.Now()
|
||||
var t time.Time
|
||||
switch preset {
|
||||
case "", "never":
|
||||
return nil, nil
|
||||
case "1d":
|
||||
t = now.Add(24 * time.Hour)
|
||||
case "7d":
|
||||
t = now.Add(7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
t = now.Add(30 * 24 * time.Hour)
|
||||
case "180d":
|
||||
t = now.Add(180 * 24 * time.Hour)
|
||||
case "365d":
|
||||
t = now.Add(365 * 24 * time.Hour)
|
||||
case "custom":
|
||||
if customDate == "" {
|
||||
return nil, fmt.Errorf("an expiration date is required")
|
||||
}
|
||||
d, err := time.ParseInLocation("2006-01-02", customDate, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
// End of the chosen day, not midnight at its start, so the picked date is
|
||||
// still valid for its whole duration.
|
||||
t = d.Add(24*time.Hour - time.Second)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid expiration option")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// addAppPassword generates a random secret (the only credential IMAP/SMTP clients ever
|
||||
// use for this mailbox — never the portal password), shows it once via flash, and
|
||||
// stores only its bcrypt hash.
|
||||
// use for this mailbox — never the portal password), reveals it once via a one-time
|
||||
// cookie the list page renders as a modal, and stores only its bcrypt hash.
|
||||
func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, ok := a.mailboxWithAccess(w, r)
|
||||
if !ok {
|
||||
@@ -34,6 +74,18 @@ func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
label = "App password"
|
||||
}
|
||||
|
||||
tzName := a.Cfg.Section("Server").Key("time_zone").MustString("UTC")
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
expiresAt, err := appPasswordExpiry(r.FormValue("expires_preset"), r.FormValue("expires_custom"), loc)
|
||||
if err != nil {
|
||||
setFlash(w, "error", err.Error())
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
|
||||
secret := db.GenerateAppPassword(minLen)
|
||||
hash, err := db.HashPassword(secret)
|
||||
@@ -42,12 +94,12 @@ func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash); err != nil {
|
||||
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash, expiresAt); err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
return
|
||||
}
|
||||
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
|
||||
setAppPasswordReveal(w, label, secret)
|
||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func TestAppPasswordExpiryPresets(t *testing.T) {
|
||||
loc := time.UTC
|
||||
now := time.Now()
|
||||
|
||||
t.Run("never expires by default", func(t *testing.T) {
|
||||
got, err := appPasswordExpiry("", "", loc)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("7 day preset", func(t *testing.T) {
|
||||
got, err := appPasswordExpiry("7d", "", loc)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("got (%v, %v), want a non-nil expiry", got, err)
|
||||
}
|
||||
wantAround := now.Add(7 * 24 * time.Hour)
|
||||
if diff := got.Sub(wantAround); diff < -time.Minute || diff > time.Minute {
|
||||
t.Fatalf("expiry %v not within a minute of %v", got, wantAround)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom date is end of day", func(t *testing.T) {
|
||||
got, err := appPasswordExpiry("custom", "2030-01-15", loc)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("got (%v, %v), want a non-nil expiry", got, err)
|
||||
}
|
||||
want := time.Date(2030, 1, 15, 23, 59, 59, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom without a date errors", func(t *testing.T) {
|
||||
if _, err := appPasswordExpiry("custom", "", loc); err == nil {
|
||||
t.Fatal("expected an error for a missing custom date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid preset errors", func(t *testing.T) {
|
||||
if _, err := appPasswordExpiry("bogus", "", loc); err == nil {
|
||||
t.Fatal("expected an error for an invalid preset")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAddAppPasswordExpiredIsRejectedByAuth confirms an app password created with a
|
||||
// past expiry (simulated directly at the DB layer, since the UI can only pick future
|
||||
// dates) can no longer authenticate, even while still marked active.
|
||||
func TestAddAppPasswordExpiredIsRejectedByAuth(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mailboxes, _ := app.DB.ListMailboxes()
|
||||
if len(mailboxes) == 0 {
|
||||
t.Fatal("no seeded mailbox")
|
||||
}
|
||||
mbox := mailboxes[0].Mailbox
|
||||
|
||||
past := time.Now().Add(-time.Hour)
|
||||
hash, err := db.HashPassword("some-secret-app-password-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.DB.CreateAppPassword(mbox.ID, "expired", hash, &past); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := app.DB.VerifyMailboxAppPassword(mbox.Email, "some-secret-app-password-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatal("expired app password must not authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddAppPasswordSetsRevealCookieNotFlash confirms the create handler no longer
|
||||
// puts the plaintext secret in the toast-driven Flash cookie (easy to miss, no copy
|
||||
// button) and instead sets the dedicated one-time reveal cookie the list page renders
|
||||
// as a modal.
|
||||
func TestAddAppPasswordSetsRevealCookieNotFlash(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
mailboxes, _ := app.DB.ListMailboxes()
|
||||
mboxID := mailboxes[0].ID
|
||||
|
||||
form := url.Values{"label": {"laptop"}, "expires_preset": {"never"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/pymta-manager/mailboxes/"+itoa(mboxID)+"/apppasswords/add", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("add app password: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var revealCookie, flashCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
switch c.Name {
|
||||
case appPasswordRevealCookieName:
|
||||
revealCookie = c
|
||||
case flashCookieName:
|
||||
flashCookie = c
|
||||
}
|
||||
}
|
||||
if revealCookie == nil || revealCookie.Value == "" {
|
||||
t.Fatal("expected a non-empty app password reveal cookie")
|
||||
}
|
||||
if flashCookie != nil && flashCookie.Value != "" {
|
||||
t.Fatalf("flash cookie should not carry the secret, got %q", flashCookie.Value)
|
||||
}
|
||||
|
||||
// Following the redirect (as the browser would) should render the reveal modal
|
||||
// with the secret, and clear the one-time cookie.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/pymta-manager/mailboxes/"+itoa(mboxID)+"/apppasswords", nil)
|
||||
req2.AddCookie(cookie)
|
||||
req2.AddCookie(revealCookie)
|
||||
rec2 := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("apppasswords list: status=%d", rec2.Code)
|
||||
}
|
||||
if !strings.Contains(rec2.Body.String(), "appPasswordRevealModal") {
|
||||
t.Fatal("expected the reveal modal markup in the response")
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestAppPasswordCannotBeRevokedFromAnotherMailbox(t *testing.T) {
|
||||
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
|
||||
mailboxB := createMailboxFor(t, app, "carol@"+domainB.DomainName, domainB.ID)
|
||||
|
||||
pwID, err := app.DB.CreateAppPassword(mailboxB.ID, "carol's laptop", "irrelevant-hash")
|
||||
pwID, err := app.DB.CreateAppPassword(mailboxB.ID, "carol's laptop", "irrelevant-hash", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ func (a *App) funcMap() template.FuncMap {
|
||||
}
|
||||
return t.Format(pyToGoLayout(layout))
|
||||
},
|
||||
// isPast reports whether a nullable expiry timestamp has already passed —
|
||||
// used to badge an app password as "Expired" even while is_active is
|
||||
// still 1 (expiry and revocation are independent states).
|
||||
"isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
|
||||
"title": strings.Title,
|
||||
"upper": strings.ToUpper,
|
||||
"lower": strings.ToLower,
|
||||
|
||||
@@ -46,7 +46,12 @@ func (a *App) settingsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
for _, name := range a.Cfg.SectionStrings() {
|
||||
sec := a.Cfg.Section(name)
|
||||
for _, k := range sec.Keys() {
|
||||
field := name + "." + k.Name()
|
||||
// Form field names are always lowercase (matches settingsPage's
|
||||
// strings.ToLower(k.Name()) template keys), but ini key names keep
|
||||
// whatever case the defaults table used (e.g. "SMTP_PORT") — comparing
|
||||
// k.Name() directly here meant every uppercase-defined key silently
|
||||
// never saved.
|
||||
field := name + "." + strings.ToLower(k.Name())
|
||||
if !r.Form.Has(field) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/config"
|
||||
)
|
||||
|
||||
// TestSettingsUpdateSavesUppercaseDefinedKeys guards against a regression where
|
||||
// settingsUpdate compared the submitted form field against the ini key's exact
|
||||
// stored case (e.g. "SMTP_PORT", as the defaults table defines it) instead of the
|
||||
// lowercase name settings.html always submits — silently dropping every edit to a
|
||||
// key whose default name isn't already lowercase.
|
||||
func TestSettingsUpdateSavesUppercaseDefinedKeys(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
|
||||
// Swap in a config produced the real way (config.Load + defaults), where keys
|
||||
// like SMTP_PORT keep their defined uppercase name — unlike newTestApp's own
|
||||
// hand-built, already-lowercase fixture cfg.
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "settings.ini")
|
||||
realCfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.Cfg = realCfg
|
||||
app.ConfigPath = configPath
|
||||
|
||||
mux := app.Mux()
|
||||
cookie := loginSession(t, app)
|
||||
|
||||
form := url.Values{"Server.smtp_port": {"2525"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/pymta-manager/settings_update", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("settings_update: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := app.Cfg.Section("Server").Key("SMTP_PORT").Value()
|
||||
if got != "2525" {
|
||||
t.Fatalf("SMTP_PORT not updated: got %q, want %q", got, "2525")
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-key me-2"></i>App Passwords <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/edit" class="btn btn-outline-warning"><i class="bi bi-shield-lock me-2"></i>Reset Mailbox Password</a>
|
||||
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it.
|
||||
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it. To reset the mailbox owner's own login password (used for the self-service portal), use <strong>Reset Mailbox Password</strong> above.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
@@ -19,6 +22,22 @@
|
||||
<label for="label" class="form-label">Label</label>
|
||||
<input type="text" class="form-control" id="label" name="label" placeholder="e.g. Thunderbird laptop">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="expires_preset" class="form-label">Expires</label>
|
||||
<select class="form-select" id="expires_preset" name="expires_preset" onchange="toggleCustomExpiry()">
|
||||
<option value="never" selected>Never</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">1 month</option>
|
||||
<option value="180d">6 months</option>
|
||||
<option value="365d">1 year</option>
|
||||
<option value="custom">Custom date…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto" id="expires_custom_wrap" style="display: none;">
|
||||
<label for="expires_custom" class="form-label">Expiration Date</label>
|
||||
<input type="date" class="form-control" id="expires_custom" name="expires_custom">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
|
||||
</div>
|
||||
@@ -32,13 +51,19 @@
|
||||
{{if .passwords}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Expires</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .passwords}}
|
||||
<tr>
|
||||
<td>{{.Label}}</td>
|
||||
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
|
||||
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
|
||||
<td>
|
||||
{{if .ExpiresAt}}
|
||||
<small class="{{if isPast .ExpiresAt}}text-danger{{else}}text-muted{{end}}">{{strftime "%Y-%m-%d %H:%M" .ExpiresAt}}</small>
|
||||
{{if isPast .ExpiresAt}}<span class="badge bg-danger ms-1">Expired</span>{{end}}
|
||||
{{else}}<small class="text-muted">Never</small>{{end}}
|
||||
</td>
|
||||
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Revoked</span>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/apppasswords/{{.ID}}/revoke" class="d-inline">
|
||||
@@ -59,4 +84,51 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .reveal}}
|
||||
<div class="modal fade" id="appPasswordRevealModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-white border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title"><i class="bi bi-key-fill me-2"></i>App Password Created</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Label: <strong>{{.reveal.Label}}</strong></p>
|
||||
<p class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Copy this password now — it will not be shown again.</p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="revealedAppPassword" value="{{.reveal.Secret}}" readonly onclick="this.select()">
|
||||
<button class="btn btn-outline-light" type="button" onclick="copyRevealedAppPassword()"><i class="bi bi-clipboard me-1"></i>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script>
|
||||
function toggleCustomExpiry() {
|
||||
const preset = document.getElementById('expires_preset').value;
|
||||
const wrap = document.getElementById('expires_custom_wrap');
|
||||
wrap.style.display = preset === 'custom' ? '' : 'none';
|
||||
document.getElementById('expires_custom').required = preset === 'custom';
|
||||
}
|
||||
|
||||
{{if .reveal}}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new bootstrap.Modal(document.getElementById('appPasswordRevealModal')).show();
|
||||
});
|
||||
function copyRevealedAppPassword() {
|
||||
const input = document.getElementById('revealedAppPassword');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value)
|
||||
.then(() => showToast('Copied to clipboard', 'success'))
|
||||
.catch(() => { document.execCommand('copy'); showToast('Copied to clipboard', 'success'); });
|
||||
}
|
||||
{{end}}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
<input type="number" class="form-control" name="Server.smtp_tls_port" value="{{.settings.Server.smtp_tls_port}}" min="1" max="65535">
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">Admin UI HTTP Port</label>
|
||||
<div class="setting-description">Plain HTTP port for this admin web interface</div>
|
||||
<input type="number" class="form-control" name="Server.web_http_port" value="{{.settings.Server.web_http_port}}" min="1" max="65535">
|
||||
</div></div>
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">Admin UI HTTPS Port</label>
|
||||
<div class="setting-description">Self-signed by default, or the Let's Encrypt cert once enabled</div>
|
||||
<input type="number" class="form-control" name="Server.web_https_port" value="{{.settings.Server.web_https_port}}" min="1" max="65535">
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">Bind IP Address</label>
|
||||
<input type="text" class="form-control" name="Server.bind_ip" value="{{.settings.Server.bind_ip}}">
|
||||
@@ -108,6 +118,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-inbox me-2"></i>IMAP Configuration</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-section">
|
||||
<div class="row">
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">IMAP Port</label>
|
||||
<div class="setting-description">Plain IMAP port (no STARTTLS offered)</div>
|
||||
<input type="number" class="form-control" name="IMAP.imap_port" value="{{.settings.IMAP.imap_port}}" min="1" max="65535">
|
||||
</div></div>
|
||||
<div class="col-md-6"><div class="mb-3"><label class="form-label">IMAP TLS Port</label>
|
||||
<div class="setting-description">Implicit-TLS IMAP port (IMAPS)</div>
|
||||
<input type="number" class="form-control" name="IMAP.imap_tls_port" value="{{.settings.IMAP.imap_tls_port}}" min="1" max="65535">
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-exclamation me-2"></i>Rspamd (Spam Scoring)</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="setting-section">
|
||||
<div class="setting-description">Optional; the built-in heuristic spam score always runs regardless of this setting.</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4"><div class="mb-3"><label class="form-label">Enabled</label>
|
||||
<select class="form-select" name="Rspamd.enabled">
|
||||
<option value="true" {{if eq .settings.Rspamd.enabled "true"}}selected{{end}}>Yes</option>
|
||||
<option value="false" {{if eq .settings.Rspamd.enabled "false"}}selected{{end}}>No</option>
|
||||
</select>
|
||||
</div></div>
|
||||
<div class="col-md-4"><div class="mb-3"><label class="form-label">Rspamd URL</label>
|
||||
<input type="text" class="form-control" name="Rspamd.url" value="{{.settings.Rspamd.url}}" placeholder="http://127.0.0.1:11333">
|
||||
</div></div>
|
||||
<div class="col-md-4"><div class="mb-3"><label class="form-label">Reject Score</label>
|
||||
<input type="number" class="form-control" name="Rspamd.reject_score" value="{{.settings.Rspamd.reject_score}}" min="1">
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
|
||||
<div class="card-body">
|
||||
@@ -201,15 +252,15 @@
|
||||
}
|
||||
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
const ports = ['Server.smtp_port', 'Server.smtp_tls_port'];
|
||||
const ports = ['Server.smtp_port', 'Server.smtp_tls_port', 'Server.web_http_port', 'Server.web_https_port', 'IMAP.imap_port', 'IMAP.imap_tls_port'];
|
||||
const seen = {};
|
||||
for (const portField of ports) {
|
||||
const input = document.querySelector(`[name="${portField}"]`);
|
||||
const port = parseInt(input.value);
|
||||
if (port < 1 || port > 65535) { e.preventDefault(); showToast(`Invalid port number: ${port}.`, 'danger'); input.focus(); return; }
|
||||
if (seen[port]) { e.preventDefault(); showToast(`Port ${port} is used more than once — each port must be different.`, 'danger'); input.focus(); return; }
|
||||
seen[port] = true;
|
||||
}
|
||||
const smtpPort = document.querySelector('[name="Server.smtp_port"]').value;
|
||||
const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value;
|
||||
if (smtpPort === tlsPort) { e.preventDefault(); showToast('SMTP and TLS ports must be different.', 'danger'); return; }
|
||||
|
||||
const serverBanner = document.querySelector('[name="Server.server_banner"]');
|
||||
if (serverBanner && !serverBanner.value.trim()) { serverBanner.value = '""'; }
|
||||
|
||||
@@ -137,7 +137,7 @@ func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash); err != nil {
|
||||
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash, nil); err != nil {
|
||||
setFlash(w, "error", "Error creating app password")
|
||||
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
||||
return
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestWebmailLoginRejectsAppPassword(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash); err != nil {
|
||||
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,20 @@ func newTestApp(t *testing.T) *App {
|
||||
serverSec, _ := cfg.NewSection("Server")
|
||||
serverSec.NewKey("smtp_port", "4025")
|
||||
serverSec.NewKey("smtp_tls_port", "40465")
|
||||
serverSec.NewKey("web_http_port", "5000")
|
||||
serverSec.NewKey("web_https_port", "5001")
|
||||
serverSec.NewKey("bind_ip", "0.0.0.0")
|
||||
serverSec.NewKey("time_zone", "UTC")
|
||||
serverSec.NewKey("hostname", "mail.example.com")
|
||||
serverSec.NewKey("helo_hostname", "mail.example.com")
|
||||
serverSec.NewKey("server_banner", "")
|
||||
imapSec, _ := cfg.NewSection("IMAP")
|
||||
imapSec.NewKey("imap_port", "1143")
|
||||
imapSec.NewKey("imap_tls_port", "1993")
|
||||
rspamdSec, _ := cfg.NewSection("Rspamd")
|
||||
rspamdSec.NewKey("enabled", "false")
|
||||
rspamdSec.NewKey("url", "http://127.0.0.1:11333")
|
||||
rspamdSec.NewKey("reject_score", "15")
|
||||
dbSec, _ := cfg.NewSection("Database")
|
||||
dbSec.NewKey("database_url", "sqlite:///server_data/smtp_server.db")
|
||||
logSec, _ := cfg.NewSection("Logging")
|
||||
|
||||
Reference in New Issue
Block a user