added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+14 -28
View File
@@ -23,29 +23,17 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
a.Logger.Error("dashboard: %v", err)
}
var domainCount, senderCount, dkimCount int
if isGlobal {
domainCount, _ = a.DB.CountActiveDomains()
senderCount, _ = a.DB.CountActiveSenders()
dkimCount, _ = a.DB.CountActiveDKIMKeys()
} else {
domains, _ := a.DB.ListDomains()
for _, d := range domains {
if d.IsActive && scope.Allowed(d.ID) {
domainCount++
}
// Domain/sender/mailbox/DKIM counts are injected uniformly into every page by
// render() (see computeNavCounts) — only "near quota" is dashboard-specific,
// so it's the only mailbox stat still computed here.
var mailboxesNearQuota int
mailboxes, _ := a.DB.ListMailboxes()
for _, m := range mailboxes {
if !m.IsActive || (!isGlobal && !scope.Allowed(m.DomainID)) {
continue
}
senders, _ := a.DB.ListSenders()
for _, s := range senders {
if s.IsActive && scope.Allowed(s.DomainID) {
senderCount++
}
}
keys, _ := a.DB.ListActiveDKIMKeysWithDomain()
for _, k := range keys {
if scope.Allowed(k.DomainID) {
dkimCount++
}
if m.QuotaBytes > 0 && float64(m.UsedBytes)/float64(m.QuotaBytes)*100 >= 90 {
mailboxesNearQuota++
}
}
@@ -75,12 +63,10 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
}
a.render(w, r, "dashboard.html", M{
"active": "dashboard",
"domain_count": domainCount,
"sender_count": senderCount,
"dkim_count": dkimCount,
"recent_emails": recentEmails,
"recent_auths": recentAuths,
"active": "dashboard",
"mailboxes_near_quota": mailboxesNearQuota,
"recent_emails": recentEmails,
"recent_auths": recentAuths,
})
}
+53
View File
@@ -0,0 +1,53 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// TestDashboardShowsMailboxNearQuota confirms the dashboard tile surfaces a mailbox
// that has crossed the 90% quota threshold, and doesn't for one that hasn't.
func TestDashboardShowsMailboxNearQuota(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app) // global admin
domains, err := app.DB.ListDomains()
if err != nil || len(domains) == 0 {
t.Fatalf("expected a seeded domain: %v", err)
}
domainID := domains[0].ID
dek := mailstore.GenerateDEK()
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
hash, err := db.HashPassword("irrelevant-portal-password")
if err != nil {
t.Fatal(err)
}
fullID, err := app.DB.CreateMailbox("full@example.com", hash, domainID, 100, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
if err := app.DB.AddMailboxUsedBytes(fullID, 95); err != nil { // 95% full
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, Prefix+"/", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard status = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "near quota") {
t.Fatal("expected the dashboard to flag a mailbox at 95% quota usage")
}
}
+115
View File
@@ -0,0 +1,115 @@
package webui
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// leAlwaysOverwriteFields are plain (non-secret) [LetsEncrypt] settings — always
// persisted from the submitted form, same as any other settings.html field.
var leAlwaysOverwriteFields = []string{
"enabled", "staging", "contact_email", "domains", "dns_provider",
"route53_region", "route53_hosted_zone_id", "gcloud_project",
}
// leSecretFields hold DNS provider credentials. They're never rendered back into the
// form (always blank) and the save handler only overwrites the stored value when the
// submitted field is non-empty — "leave blank to keep the current value", the same
// idiom edit_sender.html already uses for its password field.
var leSecretFields = []string{
"cloudflare_api_token", "route53_access_key_id", "route53_secret_access_key",
"digitalocean_api_token", "gcloud_service_account_json_path",
}
// letsEncryptPage shows the current Let's Encrypt status and configuration form.
// Secret fields are always blank in the rendered form — see leSecretFields.
func (a *App) letsEncryptPage(w http.ResponseWriter, r *http.Request) {
sec := a.Cfg.Section("LetsEncrypt")
kv := M{}
for _, k := range leAlwaysOverwriteFields {
kv[k] = sec.Key(k).String()
}
for _, k := range leSecretFields {
kv[k] = ""
}
a.render(w, r, "letsencrypt.html", M{"active": "letsencrypt", "le": kv, "status": a.ACME.Status()})
}
// letsEncryptSave is a dedicated handler (not the generic settingsUpdate reflection)
// specifically because of leSecretFields' blank-means-keep-existing semantics —
// settingsUpdate would otherwise blank out a stored credential whenever this form is
// submitted with a secret field left empty.
func (a *App) letsEncryptSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
return
}
sec := a.Cfg.Section("LetsEncrypt")
for _, k := range leAlwaysOverwriteFields {
sec.Key(k).SetValue(r.FormValue(k))
}
for _, k := range leSecretFields {
if v := r.FormValue(k); v != "" {
sec.Key(k).SetValue(v)
}
}
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
setFlash(w, "error", "Error saving settings: "+err.Error())
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
return
}
setFlash(w, "success", `Let's Encrypt settings saved. Use "Obtain / Renew Now" to test the configuration.`)
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
}
// letsEncryptObtainNow triggers an immediate obtain/renew — separate from Save, since
// saving configuration must never silently kick off an ACME transaction as a side
// effect. This is also how the very first certificate actually gets obtained.
func (a *App) letsEncryptObtainNow(w http.ResponseWriter, r *http.Request) {
if err := a.ACME.ObtainOrRenew(r.Context()); err != nil {
setFlash(w, "error", "Could not obtain certificate: "+err.Error())
} else {
setFlash(w, "success", "Certificate obtained successfully")
}
http.Redirect(w, r, Prefix+"/letsencrypt", http.StatusFound)
}
// uploadGCloudServiceAccount mirrors settings.go's uploadTLSFile two-step flow: upload
// the file, return its saved path as JSON, and the browser fills a sibling text input
// with that path — the path only actually persists once the surrounding form (Save)
// is submitted.
func (a *App) uploadGCloudServiceAccount(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid upload"})
return
}
file, header, err := r.FormFile("gcloud_key_file")
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "No file provided"})
return
}
defer file.Close()
if ext := strings.ToLower(filepath.Ext(header.Filename)); ext != ".json" {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Expected a .json service account key file"})
return
}
acmeDir := filepath.Join(filepath.Dir(a.ConfigPath), "server_data", "acme")
os.MkdirAll(acmeDir, 0o755)
filePath := filepath.Join(acmeDir, fmt.Sprintf("gcloud-sa-%d.json", time.Now().Unix()))
out, err := os.Create(filePath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
return
}
defer out.Close()
if _, err := out.ReadFrom(file); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
return
}
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
}
+77
View File
@@ -0,0 +1,77 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestLetsEncryptSaveBlankMeansKeepExisting confirms a secret field submitted blank
// doesn't wipe a previously-saved credential, while a non-empty submission does
// overwrite it — the whole reason this page has its own save handler instead of using
// the generic settingsUpdate reflection.
func TestLetsEncryptSaveBlankMeansKeepExisting(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").SetValue("original-secret-token")
// Submit the form with the secret field blank (and a plain field changed).
form := url.Values{
"enabled": {"true"},
"dns_provider": {"cloudflare"},
"domains": {"mail.example.com"},
"contact_email": {"admin@example.com"},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/letsencrypt/save", 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("expected redirect, got %d: %s", rec.Code, rec.Body.String())
}
if got := app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").String(); got != "original-secret-token" {
t.Fatalf("expected the blank submission to keep the existing token, got %q", got)
}
if got := app.Cfg.Section("LetsEncrypt").Key("enabled").String(); got != "true" {
t.Fatalf("expected the plain field to be updated, got %q", got)
}
// Now submit a real value for the secret field — it must overwrite.
form.Set("cloudflare_api_token", "new-secret-token")
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/letsencrypt/save", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d", rec2.Code)
}
if got := app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").String(); got != "new-secret-token" {
t.Fatalf("expected a non-empty submission to overwrite the token, got %q", got)
}
}
// TestLetsEncryptPageNeverRendersSecrets confirms secret fields are always blank in
// the rendered form, even when a value is stored.
func TestLetsEncryptPageNeverRendersSecrets(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
app.Cfg.Section("LetsEncrypt").Key("cloudflare_api_token").SetValue("super-secret-value")
req := httptest.NewRequest(http.MethodGet, Prefix+"/letsencrypt", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "super-secret-value") {
t.Fatal("expected the stored secret to never be rendered back into the page")
}
}
+79
View File
@@ -0,0 +1,79 @@
package webui
import (
"net/http"
"strings"
)
// aliasesList shows a mailbox's aliases plus a form to add a new one. The alias's
// domain can differ from the mailbox's own — it's resolved and access-checked
// independently, same as buildMailboxEmail/buildSenderEmail — so an alias can live on
// any domain the current admin/tenant controls, not just the mailbox's own domain.
func (a *App) aliasesList(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
aliases, err := a.DB.ListAliasesForMailbox(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error loading aliases")
}
domains, _ := a.accessibleDomains(r)
a.render(w, r, "mailbox_aliases.html", M{"active": "mailboxes", "mailbox": mailbox, "aliases": aliases, "domains": domains})
}
func (a *App) addAlias(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
localPart := strings.TrimSpace(r.FormValue("local_part"))
domainID := int64(atoi(r.FormValue("domain_id")))
canSendAs := r.FormValue("can_send_as") == "on"
if !requireDomainAccess(w, r, domainID) {
return
}
email, err := a.buildMailboxEmail(localPart, domainID)
if err != nil {
setFlash(w, "error", "Error creating alias")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
return
}
if email == "" {
setFlash(w, "error", "Please provide a valid local part (letters, numbers, and . _ % + - only) and domain")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
return
}
if exists, _ := a.DB.MailboxEmailExists(email, -1); exists {
setFlash(w, "error", "That address is already a mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
return
}
if exists, _ := a.DB.AliasEmailExists(email, -1); exists {
setFlash(w, "error", "That address is already an alias")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
return
}
if _, err := a.DB.CreateAlias(mailbox.ID, email, domainID, canSendAs); err != nil {
setFlash(w, "error", "Error creating alias")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
return
}
setFlash(w, "success", "Alias added successfully")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
}
func (a *App) removeAlias(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
aliasID := int64(atoi(r.PathValue("alias_id")))
if err := a.DB.RemoveAlias(aliasID, mailbox.ID); err != nil {
setFlash(w, "error", "Error removing alias")
} else {
setFlash(w, "success", "Alias removed")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/aliases", http.StatusFound)
}
+70
View File
@@ -0,0 +1,70 @@
package webui
import (
"net/http"
"strings"
"mailgoserver/internal/db"
)
// appPasswordsList shows a mailbox's existing app-password labels (never the secrets
// themselves — those are shown once, at creation) plus a form to add a new one.
func (a *App) appPasswordsList(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
passwords, err := a.DB.ListAppPasswordsForMailbox(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error loading app passwords")
}
a.render(w, r, "mailbox_apppasswords.html", M{"active": "mailboxes", "mailbox": mailbox, "passwords": passwords})
}
// 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.
func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
label := strings.TrimSpace(r.FormValue("label"))
if label == "" {
label = "App password"
}
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
secret := db.GenerateAppPassword(minLen)
hash, err := db.HashPassword(secret)
if err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash); 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)
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
}
func (a *App) revokeAppPassword(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
pwID := int64(atoi(r.PathValue("pw_id")))
if err := a.DB.RemoveAppPassword(pwID, mailbox.ID); err != nil {
setFlash(w, "error", "Error revoking app password")
} else {
setFlash(w, "success", "App password revoked")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
}
func idStr(r *http.Request) string {
return r.PathValue("id")
}
+63
View File
@@ -0,0 +1,63 @@
package webui
import (
"net/http"
"strings"
)
// listsPage shows a mailbox's allow-list and block-list entries plus forms to add to
// either. A single handler set keyed by list_type, backing the single
// esrv_mailbox_allowblock table (not two near-identical resources).
func (a *App) listsPage(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
entries, err := a.DB.ListAllowBlock(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error loading lists")
}
var allow, block []any
for _, e := range entries {
if e.ListType == "allow" {
allow = append(allow, e)
} else {
block = append(block, e)
}
}
a.render(w, r, "mailbox_lists.html", M{"active": "mailboxes", "mailbox": mailbox, "allow": allow, "block": block})
}
func (a *App) addAllowBlockEntry(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
listType := r.FormValue("list_type")
pattern := strings.ToLower(strings.TrimSpace(r.FormValue("pattern")))
if (listType != "allow" && listType != "block") || pattern == "" {
setFlash(w, "error", "Please provide a valid address or @domain pattern")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
return
}
if _, err := a.DB.AddAllowBlockEntry(mailbox.ID, listType, pattern); err != nil {
setFlash(w, "error", "Error adding entry")
} else {
setFlash(w, "success", "Entry added")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
}
func (a *App) removeAllowBlockEntry(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
entryID := int64(atoi(r.PathValue("entry_id")))
if err := a.DB.RemoveAllowBlockEntry(entryID, mailbox.ID); err != nil {
setFlash(w, "error", "Error removing entry")
} else {
setFlash(w, "success", "Entry removed")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
}
+166
View File
@@ -0,0 +1,166 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// createMailboxFor mirrors setupTwoTenants' sender helper, for a mailbox instead.
func createMailboxFor(t *testing.T, app *App, email string, domainID int64) *db.Mailbox {
t.Helper()
hash, err := db.HashPassword("password123")
if err != nil {
t.Fatal(err)
}
dek := mailstore.GenerateDEK()
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
id, err := app.DB.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
mbox, err := app.DB.GetMailboxByID(id)
if err != nil || mbox == nil {
t.Fatalf("expected mailbox to exist: %v", err)
}
return mbox
}
func TestScopedAdminCannotAccessOtherTenantMailboxByID(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
mailboxB := createMailboxFor(t, app, "carol@"+domainB.DomainName, domainB.ID)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
req := httptest.NewRequest(http.MethodGet, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxB.ID, 10)+"/edit", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a mailbox outside scope, got %d", rec.Code)
}
form := url.Values{}
req2 := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxB.ID, 10)+"/delete", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(cookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNotFound {
t.Fatalf("expected 404 disabling a mailbox outside scope, got %d", rec2.Code)
}
stillActive, err := app.DB.GetMailboxByID(mailboxB.ID)
if err != nil || stillActive == nil || !stillActive.IsActive {
t.Fatal("mailbox outside scope must not have been modified")
}
}
func TestScopedAdminCannotCreateMailboxOnUnownedDomain(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"local_part": {"mallory"},
"domain_id": {strconv.FormatInt(domainB.ID, 10)}, // not theirs
"password": {"password123"},
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/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.StatusNotFound {
t.Fatalf("expected 404 creating a mailbox on an unowned domain, got %d", rec.Code)
}
if m, _ := app.DB.GetMailboxByEmail("mallory@" + domainB.DomainName); m != nil {
t.Fatal("mailbox must not have been created on a domain outside the admin's scope")
}
}
func TestScopedAdminCanManageOwnMailboxAppPasswords(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, _, _, _ := setupTwoTenants(t, app)
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{"label": {"laptop"}}
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxA.ID, 10)+"/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("expected redirect after creating an app password, got %d: %s", rec.Code, rec.Body.String())
}
passwords, err := app.DB.ListAppPasswordsForMailbox(mailboxA.ID)
if err != nil || len(passwords) != 1 {
t.Fatalf("expected exactly one app password, got %d (err=%v)", len(passwords), err)
}
if len(passwords[0].PasswordHash) == 0 {
t.Fatal("expected a password hash to be stored")
}
}
func TestScopedAdminCannotCreateAliasOnUnownedDomain(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domainA, domainB, _, _ := setupTwoTenants(t, app)
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
cookie := scopedLogin(t, app, "tenant-a-admin", []int64{domainA.ID})
form := url.Values{
"local_part": {"evilalias"},
"domain_id": {strconv.FormatInt(domainB.ID, 10)}, // not theirs
}
req := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mailboxA.ID, 10)+"/aliases/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.StatusNotFound {
t.Fatalf("expected 404 creating an alias on an unowned domain, got %d", rec.Code)
}
if a, _ := app.DB.GetAliasByEmail("evilalias@" + domainB.DomainName); a != nil {
t.Fatal("alias must not have been created on a domain outside the admin's scope")
}
}
func TestAppPasswordCannotBeRevokedFromAnotherMailbox(t *testing.T) {
app := newTestApp(t)
domainA, domainB, _, _ := setupTwoTenants(t, app)
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")
if err != nil {
t.Fatal(err)
}
// Attempting to remove mailboxB's app password while scoped to mailboxA must be a
// no-op — RemoveAppPassword is scoped by (id, mailboxID) precisely to prevent this.
if err := app.DB.RemoveAppPassword(pwID, mailboxA.ID); err != nil {
t.Fatal(err)
}
remaining, err := app.DB.ListAppPasswordsForMailbox(mailboxB.ID)
if err != nil || len(remaining) != 1 {
t.Fatalf("expected mailboxB's app password to survive a delete scoped to mailboxA, got %d remaining (err=%v)", len(remaining), err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package webui
import (
"net/http"
"strconv"
"strings"
)
var validConditionFields = map[string]bool{"from": true, "to": true, "subject": true}
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true}
func (a *App) rulesList(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
rules, err := a.DB.ListRulesForMailbox(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error loading rules")
}
a.render(w, r, "mailbox_rules.html", M{"active": "mailboxes", "mailbox": mailbox, "rules": rules})
}
// addRule mirrors the compact list-CRUD pattern used elsewhere: no separate edit
// page, just add/remove — a rule needing changes is removed and re-added.
func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
priority, _ := strconv.Atoi(r.FormValue("priority"))
field := r.FormValue("condition_field")
op := r.FormValue("condition_op")
value := strings.TrimSpace(r.FormValue("condition_value"))
action := r.FormValue("action")
actionValue := strings.TrimSpace(r.FormValue("action_value"))
if !validConditionFields[field] || !validConditionOps[op] || value == "" || !validActions[action] {
setFlash(w, "error", "Please fill in a valid condition and action")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
}
if action == "move_to_folder" && actionValue == "" {
setFlash(w, "error", "Please name the folder to move matching mail into")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
}
if _, err := a.DB.CreateRule(mailbox.ID, priority, field, op, value, action, actionValue); err != nil {
setFlash(w, "error", "Error creating rule")
} else {
setFlash(w, "success", "Rule added")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
}
func (a *App) removeRule(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
ruleID := int64(atoi(r.PathValue("rule_id")))
if err := a.DB.RemoveRule(ruleID, mailbox.ID); err != nil {
setFlash(w, "error", "Error removing rule")
} else {
setFlash(w, "success", "Rule removed")
}
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
}
+231
View File
@@ -0,0 +1,231 @@
package webui
import (
"net/http"
"strconv"
"strings"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
const bytesPerGB = 1024 * 1024 * 1024
// buildMailboxEmail mirrors buildSenderEmail exactly: the domain is always resolved
// server-side by ID, never trusted as free text, so a mailbox can never end up
// assigned to a domain its own address doesn't belong to.
func (a *App) buildMailboxEmail(localPart string, domainID int64) (string, error) {
dom, err := a.DB.GetDomainByID(domainID)
if err != nil {
return "", err
}
if dom == nil || !validLocalPart.MatchString(localPart) {
return "", nil
}
return localPart + "@" + dom.DomainName, nil
}
func (a *App) mailboxesList(w http.ResponseWriter, r *http.Request) {
mailboxes, err := a.DB.ListMailboxes()
if err != nil {
setFlash(w, "error", "Error loading mailboxes")
}
scope := scopeFromContext(r)
var pairs [][2]any
for _, m := range mailboxes {
if !scope.Allowed(m.DomainID) {
continue
}
pctFull := 0.0
if m.QuotaBytes > 0 {
pctFull = float64(m.UsedBytes) / float64(m.QuotaBytes) * 100
}
pairs = append(pairs, [2]any{m.Mailbox, M{"domain_name": m.DomainName, "pct_full": pctFull}})
}
a.render(w, r, "mailboxes.html", M{"active": "mailboxes", "mailboxes": pairs})
}
func (a *App) addMailboxForm(w http.ResponseWriter, r *http.Request) {
domains, _ := a.accessibleDomains(r)
a.render(w, r, "add_mailbox.html", M{"active": "mailboxes", "domains": domains})
}
// addMailbox mirrors addSender's shape: local_part + domain_id resolved server-side
// into the real email, a random per-mailbox encryption key generated and sealed with
// the server master key (see internal/mailstore), and quota defaulting to the owning
// domain's configured default when left blank.
func (a *App) addMailbox(w http.ResponseWriter, r *http.Request) {
localPart := strings.TrimSpace(r.FormValue("local_part"))
password := r.FormValue("password")
domainID := int64(atoi(r.FormValue("domain_id")))
quotaGB := r.FormValue("quota_gb")
if !requireDomainAccess(w, r, domainID) {
return
}
email, err := a.buildMailboxEmail(localPart, domainID)
if err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
if email == "" || password == "" {
setFlash(w, "error", "All fields are required and the local part may only contain letters, numbers, and . _ % + -")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
if exists, _ := a.DB.MailboxEmailExists(email, -1); exists {
setFlash(w, "error", "A mailbox with this email already exists")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
quotaBytes := parseQuotaGB(quotaGB)
if quotaBytes <= 0 {
quotaBytes, _ = a.DB.GetDomainDefaultQuota(domainID)
}
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
dek := mailstore.GenerateDEK()
wrapped, nonce, err := a.Mailstore.WrapDEK(dek)
if err != nil {
a.Logger.Error("wrap mailbox DEK: %v", err)
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
if _, err := a.DB.CreateMailbox(email, hash, domainID, quotaBytes, wrapped, nonce); err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
setFlash(w, "success", "Mailbox added successfully")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func parseQuotaGB(s string) int64 {
gb, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil || gb <= 0 {
return 0
}
return int64(gb * bytesPerGB)
}
// mailboxWithAccess mirrors senderWithAccess.
func (a *App) mailboxWithAccess(w http.ResponseWriter, r *http.Request) (mailbox *db.Mailbox, ok bool) {
mailbox, err := a.DB.GetMailboxByID(pathID(r))
if err != nil || mailbox == nil {
http.NotFound(w, r)
return nil, false
}
if !requireDomainAccess(w, r, mailbox.DomainID) {
return nil, false
}
return mailbox, true
}
func (a *App) disableMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetMailboxActive(mailbox.ID, false); err != nil {
setFlash(w, "error", "Error disabling mailbox")
} else {
setFlash(w, "success", "Mailbox disabled")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func (a *App) enableMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetMailboxActive(mailbox.ID, true); err != nil {
setFlash(w, "error", "Error enabling mailbox")
} else {
setFlash(w, "success", "Mailbox enabled")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
// removeMailbox deletes every stored message's on-disk ciphertext via mailstore first
// (so nothing is orphaned on disk), then hard-deletes the mailbox row and everything
// that references it.
func (a *App) removeMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
uids, err := a.DB.ListMessageUIDsForMailbox(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error removing mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
for _, uid := range uids {
if err := a.Mailstore.DeleteMessage(mailbox.ID, uid); err != nil {
a.Logger.Error("delete message %d for mailbox %d: %v", uid, mailbox.ID, err)
}
}
if err := a.DB.RemoveMailboxCascade(mailbox.ID); err != nil {
setFlash(w, "error", "Error removing mailbox")
} else {
setFlash(w, "success", "Mailbox permanently removed")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func (a *App) editMailboxForm(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
domains, _ := a.accessibleDomains(r)
a.render(w, r, "edit_mailbox.html", M{
"active": "mailboxes", "mailbox": mailbox, "domains": domains,
"local_part": localPartOf(mailbox.Email), "quota_gb": float64(mailbox.QuotaBytes) / bytesPerGB,
})
}
// editMailbox allows changing the portal password and quota. The local part/domain
// (and so the address itself) are intentionally NOT editable here — the IMAP/SMTP app
// passwords and encryption key are already bound to this mailbox's identity, and
// renaming it out from under those would orphan them. Remove and recreate instead.
func (a *App) editMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
password := r.FormValue("password")
quotaBytes := parseQuotaGB(r.FormValue("quota_gb"))
if quotaBytes <= 0 {
quotaBytes = mailbox.QuotaBytes
}
if err := a.DB.SetMailboxQuota(mailbox.ID, quotaBytes); err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
if password != "" {
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
if err := a.DB.SetMailboxPasswordHash(mailbox.ID, hash); err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
}
setFlash(w, "success", "Mailbox updated successfully")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
+58
View File
@@ -0,0 +1,58 @@
package webui
import "net/http"
// navCounts are the small per-resource counts shown as sidebar badges on every page
// (and reused by the dashboard's own stat tiles, which use the same numbers) — scoped
// to the current admin exactly like every list page already is.
type navCounts struct {
DomainCount, SenderCount, MailboxCount, IPCount, DKIMCount int
}
func (a *App) computeNavCounts(r *http.Request) navCounts {
scope := scopeFromContext(r)
var c navCounts
if scope.Global {
c.DomainCount, _ = a.DB.CountActiveDomains()
c.SenderCount, _ = a.DB.CountActiveSenders()
c.DKIMCount, _ = a.DB.CountActiveDKIMKeys()
} else {
domains, _ := a.DB.ListDomains()
for _, d := range domains {
if d.IsActive && scope.Allowed(d.ID) {
c.DomainCount++
}
}
senders, _ := a.DB.ListSenders()
for _, s := range senders {
if s.IsActive && scope.Allowed(s.DomainID) {
c.SenderCount++
}
}
keys, _ := a.DB.ListActiveDKIMKeysWithDomain()
for _, k := range keys {
if scope.Allowed(k.DomainID) {
c.DKIMCount++
}
}
}
// Mailboxes and IPs always need a per-row pass regardless of scope.Global (no
// dedicated CountActive* helpers exist for them), same as dashboard.go already did
// for mailboxes before this was centralized.
mailboxes, _ := a.DB.ListMailboxes()
for _, m := range mailboxes {
if m.IsActive && (scope.Global || scope.Allowed(m.DomainID)) {
c.MailboxCount++
}
}
ips, _ := a.DB.ListWhitelistedIPs()
for _, ip := range ips {
if ip.IsActive && (scope.Global || scope.Allowed(ip.DomainID)) {
c.IPCount++
}
}
return c
}
+31 -3
View File
@@ -20,7 +20,21 @@ type M map[string]any
func (a *App) funcMap() template.FuncMap {
return template.FuncMap{
"formatDatetime": func(t time.Time) string { return formatDatetimeInZone(t, a.Cfg) },
"strftime": func(layout string, t time.Time) string {
// strftime accepts either time.Time or *time.Time (nullable DB columns like
// MailboxAppPassword.LastUsedAt) so callers don't need a separate deref helper.
"strftime": func(layout string, v any) string {
var t time.Time
switch tv := v.(type) {
case time.Time:
t = tv
case *time.Time:
if tv == nil {
return ""
}
t = *tv
default:
return ""
}
if t.IsZero() {
return ""
}
@@ -109,16 +123,21 @@ func humanFileSize(size int64) string {
var pages = []string{
"dashboard.html", "domains.html", "add_domain.html", "edit_domain.html",
"senders.html", "add_sender.html", "edit_sender.html",
"mailboxes.html", "add_mailbox.html", "edit_mailbox.html", "mailbox_apppasswords.html", "mailbox_aliases.html",
"mailbox_lists.html", "mailbox_rules.html",
"ips.html", "add_ip.html", "edit_ip.html",
"dkim.html", "edit_dkim.html",
"settings.html", "logs.html", "view_message_content.html", "error.html",
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
"account.html", "first_login.html", "totp_setup.html",
"admins.html", "add_admin.html", "edit_admin.html",
}
// standalonePages are pre-login screens — they intentionally don't use base.html's
// sidebar/dashboard chrome, since the visitor isn't authenticated yet.
var standalonePages = []string{"login.html", "login_mfa.html"}
var standalonePages = []string{
"login.html", "login_mfa.html",
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html",
}
// loadTemplates parses from the embedded assets FS (see embed.go), not the
// filesystem — the binary carries its own templates, so it runs from any working
@@ -173,6 +192,15 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
}
data["flashes"] = popFlashes(w, r)
data["health"] = a.checkHealth()
// Sidebar badge counts (Domains/Senders/Mailboxes/IPs/DKIM Keys) — computed here,
// centrally, so every authenticated page shows them, not just the dashboard (which
// used to compute these itself and nowhere else did).
counts := a.computeNavCounts(r)
data["domain_count"] = counts.DomainCount
data["sender_count"] = counts.SenderCount
data["mailbox_count"] = counts.MailboxCount
data["ip_count"] = counts.IPCount
data["dkim_count"] = counts.DKIMCount
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base.html", data); err != nil {
a.Logger.Error("template render error (%s): %v", page, err)
+44
View File
@@ -0,0 +1,44 @@
{{define "title"}}Add Mailbox - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header"><h4 class="mb-0"><i class="bi bi-mailbox me-2"></i>Add New Mailbox</h4></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="local_part" class="form-label">Email Address</label>
<div class="input-group">
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="user"
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
<span class="input-group-text">@</span>
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
</div>
<div class="form-text">This is the mailbox's login username — it never changes, even if aliases are added later.</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required minlength="8">
<div class="form-text">Used for the self-service account portal only — never for IMAP/SMTP clients. Those use a separate app password (create one after adding this mailbox).</div>
</div>
<div class="mb-4">
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" placeholder="5">
<div class="form-text">Leave blank to use the domain's default quota.</div>
</div>
<div class="d-flex justify-content-between">
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
<button type="submit" class="btn btn-success"><i class="bi bi-mailbox me-2"></i>Add Mailbox</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
+64 -1
View File
@@ -5,6 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Email Server Management{{end}}</title>
<script>
// Applied before first paint so an unpinned sidebar starts collapsed, not
// pinned-then-flashing-collapsed once the stylesheet/JS below catches up.
if (localStorage.getItem('sidebarPinned') === 'false') {
document.documentElement.classList.add('sidebar-unpinned');
}
</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">
@@ -13,6 +21,17 @@
body { background-color: #1a1a1a; color: #e0e0e0; }
.main-container { display: flex; min-height: 100vh; }
.content-area { flex: 1; margin-left: var(--sidebar-width); padding: 20px; transition: margin-left 0.3s ease; }
/* Unpinned sidebar: collapsed off-screen, floats over full-width content until
pinned again. Revealed by the toggle button or by moving the mouse to the
left edge (see JS below); hidden again on mouseleave. */
html.sidebar-unpinned .sidebar { transform: translateX(-100%); }
html.sidebar-unpinned .sidebar.sidebar-open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5); }
html.sidebar-unpinned .content-area { margin-left: 0 !important; }
/* Sits in the page-title bar, not fixed over the page — so when the sidebar
peeks open (z-index above content), it naturally covers this button instead
of the button floating on top of the sidebar's own header. */
.sidebar-toggle-btn { display: none; }
html.sidebar-unpinned .sidebar-toggle-btn { display: inline-flex; }
.navbar-brand { color: #fff !important; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.table-dark { --bs-table-bg: #2d2d2d; --bs-table-border-color: #404040; }
@@ -52,7 +71,10 @@
<div class="content-area">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="navbar-brand mb-0 h1 d-flex align-items-center">
<button id="sidebarToggleBtn" class="btn btn-sm btn-outline-light me-2 sidebar-toggle-btn" title="Show sidebar" onclick="showSidebarPeek()">
<i class="bi bi-list"></i>
</button>
<i class="bi bi-envelope-fill me-2"></i>
{{block "page_title" .}}Email Server Management{{end}}
</span>
@@ -118,6 +140,47 @@
setInterval(updateTime, 1000);
updateTime();
// Sidebar pin/unpin — pinned (default) keeps today's always-visible behavior.
// Unpinned collapses it off-screen so the content area gets the full width;
// it re-appears as an overlay via the toggle button or by nudging the mouse to
// the left edge, and hides again once the mouse leaves it.
function isSidebarPinned() { return localStorage.getItem('sidebarPinned') !== 'false'; }
function updateSidebarPinUI() {
const icon = document.getElementById('sidebarPinIcon');
const btn = document.getElementById('sidebarPinBtn');
if (!icon || !btn) return;
const pinned = isSidebarPinned();
icon.className = pinned ? 'bi bi-pin-angle-fill' : 'bi bi-pin-angle';
btn.title = pinned ? 'Unpin sidebar (auto-hide)' : 'Pin sidebar (keep open)';
}
function hideSidebarPeek() {
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) sidebarEl.classList.remove('sidebar-open');
}
function showSidebarPeek() {
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) sidebarEl.classList.add('sidebar-open');
}
function toggleSidebarPin() {
const pinned = !isSidebarPinned();
localStorage.setItem('sidebarPinned', pinned ? 'true' : 'false');
document.documentElement.classList.toggle('sidebar-unpinned', !pinned);
updateSidebarPinUI();
hideSidebarPeek();
}
document.addEventListener('DOMContentLoaded', function() {
updateSidebarPinUI();
const sidebarEl = document.querySelector('.sidebar');
if (sidebarEl) {
sidebarEl.addEventListener('mouseleave', function() {
if (!isSidebarPinned()) hideSidebarPeek();
});
}
document.addEventListener('mousemove', function(e) {
if (!isSidebarPinned() && e.clientX <= 15) showSidebarPeek();
});
});
// Notifications auto-dismiss after 5s, but hovering (reading, or selecting
// text to copy) pauses the timer — it only resumes once the mouse leaves.
// Clicking inside never dismisses; only the X button or the timer does.
+21
View File
@@ -54,6 +54,27 @@
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<a href="/pymta-manager/mailboxes" class="text-decoration-none">
<div class="card {{if gt .mailboxes_near_quota 0}}border-danger{{else}}border-primary{{end}}">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<h5 class="card-title {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} mb-1"><i class="bi bi-inbox me-2"></i>Mailboxes</h5>
<h3 class="mb-0">{{.mailbox_count}}</h3>
{{if gt .mailboxes_near_quota 0}}
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{.mailboxes_near_quota}} near quota</small>
{{else}}
<small class="text-muted">Active mailboxes</small>
{{end}}
</div>
<div class="fs-2 {{if gt .mailboxes_near_quota 0}}text-danger{{else}}text-primary{{end}} opacity-50"><i class="bi bi-inbox"></i></div>
</div>
</div>
</div>
</a>
</div>
<div class="col-lg-3 col-md-6 mb-4">
<div class="card border-info">
<div class="card-body">
@@ -0,0 +1,55 @@
{{define "title"}}Edit Mailbox - SMTP Management{{end}}
{{define "content"}}
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-mailbox2 me-2"></i>Edit Mailbox</h5></div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label">Email Address</label>
<input type="text" class="form-control" value="{{.mailbox.Email}}" disabled>
<div class="form-text">The address can't be changed here — app passwords and stored mail are bound to it. Remove and recreate the mailbox instead.</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Leave blank to keep current password">
<div class="form-text">Only enter a password if you want to change it — used for the self-service portal, never IMAP/SMTP.</div>
</div>
<div class="mb-3">
<label for="quota_gb" class="form-label">Storage Quota (GB)</label>
<input type="number" class="form-control" id="quota_gb" name="quota_gb" min="0.1" step="0.1" value="{{printf "%.2f" .quota_gb}}">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Mailbox</button>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-x-lg me-1"></i>Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>Current Mailbox Details</h6></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Email:</dt><dd class="col-sm-7"><code>{{.mailbox.Email}}</code></dd>
<dt class="col-sm-5">Domain:</dt>
<dd class="col-sm-7">{{range .domains}}{{if eq .ID $.mailbox.DomainID}}<span class="badge bg-secondary">{{.DomainName}}</span>{{end}}{{end}}</dd>
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">{{if .mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}</dd>
<dt class="col-sm-5">Storage:</dt>
<dd class="col-sm-7"><small class="text-muted">{{filesize .mailbox.UsedBytes}} / {{filesize .mailbox.QuotaBytes}}</small></dd>
<dt class="col-sm-5">Created:</dt><dd class="col-sm-7"><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .mailbox.CreatedAt}}</small></dd>
</dl>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-key me-1"></i>Manage App Passwords</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-signpost-split me-1"></i>Manage Aliases</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-shield-exclamation me-1"></i>Allow/Block List</a>
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules" class="btn btn-outline-secondary btn-sm w-100 mt-2"><i class="bi bi-funnel me-1"></i>Filter Rules</a>
</div>
</div>
</div>
</div>
{{end}}
+11 -11
View File
@@ -7,12 +7,10 @@
<a href="/pymta-manager/ips/add" class="btn btn-success"><i class="bi bi-plus-circle me-2"></i>Add IP Address</a>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
<div class="card-body">
{{if .ips}}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Whitelisted IP Addresses</h5></div>
<div class="card-body">
{{if .ips}}
<div class="table-responsive">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Domain</th><th>Status</th><th>Storage Type</th><th>Added</th><th>Actions</th></tr></thead>
@@ -56,14 +54,14 @@
</div>
{{end}}
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-info-circle me-2"></i>IP Whitelist Information</h6></div>
<div class="card-body">
<div class="alert alert-info">
<div class="alert alert-info mb-0">
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>How IP Whitelisting Works</h6>
<ul class="mb-0 small">
<li>Whitelisted IPs can send emails without username/password authentication</li>
@@ -74,8 +72,10 @@
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-geo-alt me-2"></i>Your Current IP</h6></div>
<div class="card-body">
<div class="text-center">
+173
View File
@@ -0,0 +1,173 @@
{{define "title"}}Let's Encrypt - Email Server Management{{end}}
{{define "page_title"}}Let's Encrypt{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-patch-check me-2"></i>Let's Encrypt</h2>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Status</h5></div>
<div class="card-body">
<dl class="row mb-3">
<dt class="col-sm-3">Mode</dt>
<dd class="col-sm-9">
{{if .status.Enabled}}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Let's Encrypt {{if .status.Staging}}(staging){{end}}</span>
{{else}}
<span class="badge bg-secondary"><i class="bi bi-dash-circle me-1"></i>Self-signed (Let's Encrypt disabled)</span>
{{end}}
</dd>
<dt class="col-sm-3">Domains</dt>
<dd class="col-sm-9">{{if .status.Domains}}{{range .status.Domains}}<code>{{.}}</code> {{end}}{{else}}<span class="text-muted">none configured</span>{{end}}</dd>
<dt class="col-sm-3">Provider</dt>
<dd class="col-sm-9">{{if .status.Provider}}{{.status.Provider}}{{else}}<span class="text-muted">none selected</span>{{end}}</dd>
<dt class="col-sm-3">Certificate expires</dt>
<dd class="col-sm-9">{{if .status.NotAfter.IsZero}}<span class="text-muted">unknown</span>{{else}}{{strftime "%Y-%m-%d %H:%M" .status.NotAfter}}{{end}}</dd>
<dt class="col-sm-3">Last attempt</dt>
<dd class="col-sm-9">
{{if .status.LastAttempt.IsZero}}
<span class="text-muted">none yet this run</span>
{{else if .status.LastError}}
<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — {{.status.LastError}}</span>
{{else}}
<span class="text-success"><i class="bi bi-check-circle me-1"></i>{{strftime "%Y-%m-%d %H:%M" .status.LastAttempt}} — success</span>
{{end}}
</dd>
</dl>
<form method="post" action="/pymta-manager/letsencrypt/obtain">
<button type="submit" class="btn btn-primary" data-confirm="Obtain or renew the certificate now using the saved configuration?"><i class="bi bi-arrow-repeat me-1"></i>Obtain / Renew Now</button>
</form>
</div>
</div>
<form method="POST" action="/pymta-manager/letsencrypt/save">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h5></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Enable Let's Encrypt</label>
<select class="form-select" name="enabled">
<option value="false" {{if ne .le.enabled "true"}}selected{{end}}>No — keep the self-signed certificate</option>
<option value="true" {{if eq .le.enabled "true"}}selected{{end}}>Yes</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Staging mode</label>
<select class="form-select" name="staging">
<option value="false" {{if ne .le.staging "true"}}selected{{end}}>No — request a real, trusted certificate</option>
<option value="true" {{if eq .le.staging "true"}}selected{{end}}>Yes — untrusted test certificate, no rate limits</option>
</select>
<div class="form-text">Recommended while testing a new configuration.</div>
</div>
<div class="mb-3">
<label class="form-label">Contact Email</label>
<input type="email" class="form-control" name="contact_email" value="{{.le.contact_email}}">
</div>
<div class="mb-3">
<label class="form-label">Domains</label>
<input type="text" class="form-control font-monospace" name="domains" value="{{.le.domains}}" placeholder="mail.example.com,*.mail.example.com">
<div class="form-text">Comma-separated. Include a wildcard entry (e.g. <code>*.mail.example.com</code>) alongside its bare domain to cover both with one certificate.</div>
</div>
<div class="mb-3">
<label class="form-label">DNS Provider</label>
<select class="form-select" name="dns_provider" id="le_provider">
<option value="">Select a provider...</option>
<option value="cloudflare" {{if eq .le.dns_provider "cloudflare"}}selected{{end}}>Cloudflare</option>
<option value="route53" {{if eq .le.dns_provider "route53"}}selected{{end}}>AWS Route53</option>
<option value="digitalocean" {{if eq .le.dns_provider "digitalocean"}}selected{{end}}>DigitalOcean</option>
<option value="gcloud" {{if eq .le.dns_provider "gcloud"}}selected{{end}}>Google Cloud DNS</option>
</select>
</div>
<div class="provider-fields" id="fields-cloudflare">
<div class="setting-section mb-3">
<h6>Cloudflare</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="cloudflare_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-route53">
<div class="setting-section mb-3">
<h6>AWS Route53</h6>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input type="password" class="form-control" name="route53_access_key_id" placeholder="Leave blank to keep the current value, or blank both keys to use the host's AWS credential chain">
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input type="password" class="form-control" name="route53_secret_access_key" placeholder="Leave blank to keep the current value">
</div>
<div class="mb-3">
<label class="form-label">Region</label>
<input type="text" class="form-control" name="route53_region" value="{{.le.route53_region}}" placeholder="us-east-1">
</div>
<div class="mb-3">
<label class="form-label">Hosted Zone ID (optional)</label>
<input type="text" class="form-control" name="route53_hosted_zone_id" value="{{.le.route53_hosted_zone_id}}" placeholder="Leave blank to auto-discover">
</div>
</div>
</div>
<div class="provider-fields" id="fields-digitalocean">
<div class="setting-section mb-3">
<h6>DigitalOcean</h6>
<div class="mb-3">
<label class="form-label">API Token</label>
<input type="password" class="form-control" name="digitalocean_api_token" placeholder="Leave blank to keep the current value">
</div>
</div>
</div>
<div class="provider-fields" id="fields-gcloud">
<div class="setting-section mb-3">
<h6>Google Cloud DNS</h6>
<div class="mb-3">
<label class="form-label">Project ID</label>
<input type="text" class="form-control" name="gcloud_project" value="{{.le.gcloud_project}}">
</div>
<div class="mb-3">
<label class="form-label">Service Account Key (optional)</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" name="gcloud_service_account_json_path" id="gcloud_sa_path" placeholder="Leave blank to use Application Default Credentials">
<input type="file" class="d-none" id="gcloudKeyUpload" accept=".json">
<button class="btn btn-outline-secondary" type="button" onclick="document.getElementById('gcloudKeyUpload').click()"><i class="bi bi-upload"></i></button>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-check-lg me-1"></i>Save Configuration</button>
</div>
</div>
</form>
{{end}}
{{define "extra_js"}}
<script>
function updateProviderFields() {
const selected = document.getElementById('le_provider').value;
document.querySelectorAll('.provider-fields').forEach(function(el) {
el.style.display = (el.id === 'fields-' + selected) ? '' : 'none';
});
}
document.getElementById('le_provider').addEventListener('change', updateProviderFields);
updateProviderFields();
document.getElementById('gcloudKeyUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('gcloud_key_file', file);
fetch('/pymta-manager/api/letsencrypt/upload_gcloud_key', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status === 'success') { document.getElementById('gcloud_sa_path').value = data.filepath; showToast('Service account key uploaded', 'success'); }
else { showToast(data.message || 'Failed to upload key', 'danger'); }
}).catch(() => showToast('Failed to upload key', 'danger'));
});
</script>
{{end}}
@@ -0,0 +1,76 @@
{{define "title"}}Aliases - Email Server{{end}}
{{define "page_title"}}Aliases{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-signpost-split me-2"></i>Aliases <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>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>An alias lets this mailbox receive mail at another address. Enable "send as" to also let it send mail using that address. The login address is always <code>{{.mailbox.Email}}</code> — aliases never change that.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Alias</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/aliases/add">
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="local_part" name="local_part" required placeholder="alias"
pattern="[a-zA-Z0-9._%+-]+" title="Letters, numbers, and . _ % + - only">
<span class="input-group-text">@</span>
<select class="form-select" id="domain_id" name="domain_id" required style="max-width: 260px;">
<option value="">Select a domain...</option>
{{range .domains}}<option value="{{.ID}}">{{.DomainName}}</option>{{end}}
</select>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="can_send_as" name="can_send_as">
<label class="form-check-label" for="can_send_as"><strong>Allow sending as this address</strong></label>
<div class="form-text">If enabled, this mailbox can use MAIL FROM with this alias once authenticated with its app password.</div>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="bi bi-signpost-split me-2"></i>Add Alias</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing Aliases</h5></div>
<div class="card-body p-0">
{{if .aliases}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Address</th><th>Permissions</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .aliases}}
<tr>
<td>{{.Email}}</td>
<td>
{{if .CanSendAs}}<span class="badge bg-warning text-dark"><i class="bi bi-send me-1"></i>Receive &amp; Send</span>
{{else}}<span class="badge bg-secondary"><i class="bi bi-inbox me-1"></i>Receive Only</span>{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/aliases/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove alias {{.Email}}?"><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-signpost-split text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No aliases yet</h4>
<p class="text-muted">Add one above to let this mailbox receive mail at another address.</p>
</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,62 @@
{{define "title"}}App Passwords - Email Server{{end}}
{{define "page_title"}}App Passwords{{end}}
{{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>
<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.
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Create App Password</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/apppasswords/add" class="row g-2 align-items-end">
<div class="col-auto">
<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">
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>Existing App Passwords</h5></div>
<div class="card-body p-0">
{{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>
<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 .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">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Revoke" data-confirm="Revoke app password &quot;{{.Label}}&quot;? Any client using it will stop working."><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-key text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No app passwords yet</h4>
<p class="text-muted">Create one above to connect a mail client to this mailbox.</p>
</div>
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,69 @@
{{define "title"}}Allow/Block List - Email Server{{end}}
{{define "page_title"}}Allow/Block List{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-exclamation me-2"></i>Allow/Block List <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>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>A pattern is either an exact address (<code>spam@evil.com</code>) or a whole domain (<code>@evil.com</code>). Block entries reject mail at RCPT time; allow entries bypass spam scoring entirely for that sender.
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0 text-success"><i class="bi bi-check-circle me-2"></i>Allow List</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
<input type="hidden" name="list_type" value="allow">
<input type="text" class="form-control" name="pattern" placeholder="friend@example.com or @example.com" required>
<button type="submit" class="btn btn-success"><i class="bi bi-plus-lg"></i></button>
</form>
{{if .allow}}
<ul class="list-group list-group-flush">
{{range .allow}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
<code>{{.Pattern}}</code>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the allow list?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted mb-0">No allow-list entries.</p>
{{end}}
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0 text-danger"><i class="bi bi-x-circle me-2"></i>Block List</h5></div>
<div class="card-body">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/lists/add" class="d-flex gap-2 mb-3">
<input type="hidden" name="list_type" value="block">
<input type="text" class="form-control" name="pattern" placeholder="spam@evil.com or @evil.com" required>
<button type="submit" class="btn btn-danger"><i class="bi bi-plus-lg"></i></button>
</form>
{{if .block}}
<ul class="list-group list-group-flush">
{{range .block}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
<code>{{.Pattern}}</code>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/lists/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove {{.Pattern}} from the block list?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted mb-0">No block-list entries.</p>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+104
View File
@@ -0,0 +1,104 @@
{{define "title"}}Filter Rules - Email Server{{end}}
{{define "page_title"}}Filter Rules{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-funnel me-2"></i>Filter Rules <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>
<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 IMAP folder instead of INBOX — your mail client will show it once mail has 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="/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;">
</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>
</form>
</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>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{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="/pymta-manager/mailboxes/{{$.mailbox.ID}}/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>
{{end}}
{{define "extra_js"}}
<script>
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';
});
</script>
{{end}}
+70
View File
@@ -0,0 +1,70 @@
{{define "title"}}Mailboxes - Email Server Management{{end}}
{{define "page_title"}}Mailbox Management{{end}}
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-inbox me-2"></i>Mailboxes</h2>
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Mailbox</a>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list-ul me-2"></i>All Mailboxes</h5></div>
<div class="card-body p-0">
{{if .mailboxes}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Domain</th><th>Status</th><th>Storage</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>
{{range .mailboxes}}
{{$mailbox := index . 0}}{{$extra := index . 1}}
<tr>
<td><div class="fw-bold">{{$mailbox.Email}}</div></td>
<td><span class="badge bg-secondary">{{$extra.domain_name}}</span></td>
<td>
{{if $mailbox.IsActive}}<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Active</span>
{{else}}<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Inactive</span>{{end}}
</td>
<td>
<small class="text-muted">{{filesize $mailbox.UsedBytes}} / {{filesize $mailbox.QuotaBytes}}</small>
{{if ge $extra.pct_full 90.0}}
<span class="badge bg-danger ms-1"><i class="bi bi-exclamation-octagon me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
{{else if ge $extra.pct_full 75.0}}
<span class="badge bg-warning text-dark ms-1"><i class="bi bi-exclamation-triangle me-1"></i>{{printf "%.0f" $extra.pct_full}}% full</span>
{{end}}
</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" $mailbox.CreatedAt}}</small></td>
<td>
<div class="btn-group" role="group">
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/apppasswords" class="btn btn-outline-secondary btn-sm" title="App Passwords"><i class="bi bi-key"></i></a>
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/aliases" class="btn btn-outline-secondary btn-sm" title="Aliases"><i class="bi bi-signpost-split"></i></a>
<a href="/pymta-manager/mailboxes/{{$mailbox.ID}}/edit" class="btn btn-outline-primary btn-sm" title="Edit Mailbox"><i class="bi bi-pencil"></i></a>
{{if $mailbox.IsActive}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-warning btn-sm" title="Disable Mailbox" data-confirm="Disable mailbox {{$mailbox.Email}}?"><i class="bi bi-pause-circle"></i></button>
</form>
{{else}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/enable" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Enable Mailbox" data-confirm="Enable mailbox {{$mailbox.Email}}?"><i class="bi bi-play-circle"></i></button>
</form>
{{end}}
<form method="post" action="/pymta-manager/mailboxes/{{$mailbox.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Permanently Remove Mailbox" data-confirm="Permanently remove mailbox {{$mailbox.Email}} and all its stored mail? This cannot be undone!"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 4rem;"></i>
<h4 class="text-muted mt-3">No mailboxes configured</h4>
<p class="text-muted">Add a mailbox to let a client app (Thunderbird, etc.) receive mail via IMAP</p>
<a href="/pymta-manager/mailboxes/add" class="btn btn-primary"><i class="bi bi-mailbox me-2"></i>Add Your First Mailbox</a>
</div>
{{end}}
</div>
</div>
{{end}}
+26 -6
View File
@@ -1,12 +1,17 @@
{{define "sidebar_email.html"}}
<nav class="sidebar bg-dark border-end border-secondary position-fixed h-100" style="width: var(--sidebar-width); z-index: 1000;">
<div class="d-flex flex-column h-100">
<div class="p-3 border-bottom border-secondary">
<h5 class="text-white mb-0">
<i class="bi bi-server me-2"></i>
SMTP Server
</h5>
<small class="text-muted">Management Console</small>
<div class="p-3 border-bottom border-secondary d-flex align-items-start justify-content-between">
<div>
<h5 class="text-white mb-0">
<i class="bi bi-server me-2"></i>
SMTP Server
</h5>
<small class="text-muted">Management Console</small>
</div>
<button id="sidebarPinBtn" class="btn btn-sm btn-outline-secondary" title="Unpin sidebar (auto-hide)" onclick="toggleSidebarPin()">
<i class="bi bi-pin-angle-fill" id="sidebarPinIcon"></i>
</button>
</div>
<div class="flex-grow-1 overflow-auto">
@@ -41,6 +46,14 @@
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/mailboxes" class="nav-link text-white {{if eq (dget . "active") "mailboxes"}}active{{end}}">
<i class="bi bi-inbox me-2"></i>
Mailboxes
<span class="badge bg-secondary ms-auto">{{dget . "mailbox_count"}}</span>
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/ips" class="nav-link text-white {{if eq (dget . "active") "ips"}}active{{end}}">
<i class="bi bi-router me-2"></i>
@@ -57,6 +70,13 @@
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/letsencrypt" class="nav-link text-white {{if eq (dget . "active") "letsencrypt"}}active{{end}}">
<i class="bi bi-patch-check me-2"></i>
Let's Encrypt
</a>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/logs" class="nav-link text-white {{if eq (dget . "active") "logs"}}active{{end}}">
<i class="bi bi-journal-text me-2"></i>
@@ -0,0 +1,273 @@
{{define "webmail_account.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>{{.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">
<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>
<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>
</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="row">
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd me-2"></i>Storage</h5></div>
<div class="card-body">
<div class="progress mb-2" style="height: 1.25rem;">
<div class="progress-bar {{if ge .pct_full 90.0}}bg-danger{{else if ge .pct_full 75.0}}bg-warning{{else}}bg-success{{end}}" style="width: {{printf "%.0f" .pct_full}}%">{{printf "%.0f" .pct_full}}%</div>
</div>
<small class="text-muted">{{filesize .mailbox.UsedBytes}} of {{filesize .mailbox.QuotaBytes}} used</small>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
<div class="card-body">
<form method="POST" action="/webmail/account/password">
<div class="mb-3">
<label class="form-label">Current Password</label>
<input type="password" class="form-control" name="current_password" required>
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="new_password" required minlength="10">
<div class="form-text">At least 10 characters, with a letter, a number, and a symbol.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" name="new_password_confirm" required>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Update Password</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Two-Factor Authentication</h5></div>
<div class="card-body">
<h6>Authenticator App</h6>
{{if .mailbox.TOTPEnabled}}
<p class="text-success"><i class="bi bi-check-circle me-1"></i>Enabled</p>
<form method="post" action="/webmail/account/totp/disable">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Disable authenticator app MFA?">Disable</button>
</form>
{{else}}
<p class="text-muted">Not enabled.</p>
<form method="post" action="/webmail/account/totp/setup">
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-qr-code me-1"></i>Set Up</button>
</form>
{{end}}
<hr>
<h6>Passkeys</h6>
{{if .passkeys}}
<ul class="list-group list-group-flush mb-3">
{{range .passkeys}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
{{.Name}}
<form method="post" action="/webmail/account/passkey/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove passkey &quot;{{.Name}}&quot;?"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted">No passkeys registered.</p>
{{end}}
<button type="button" class="btn btn-outline-primary btn-sm" id="passkey-add-btn"><i class="bi bi-fingerprint me-1"></i>Add a Passkey</button>
<div id="passkey-error" class="alert alert-danger d-none mt-2"></div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key me-2"></i>App Passwords</h5></div>
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Use an app password (never your account password) to set up this mailbox in Thunderbird or any other mail client.
</div>
<form method="POST" action="/webmail/account/apppasswords/add" class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label">Label</label>
<input type="text" class="form-control" name="label" placeholder="e.g. Thunderbird laptop">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
</div>
</form>
{{if .passwords}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Last Used</th><th></th></tr></thead>
<tbody>
{{range .passwords}}
<tr>
<td>{{.Label}}</td>
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
<td>
<form method="post" action="/webmail/account/apppasswords/{{.ID}}/revoke" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Revoke app password &quot;{{.Label}}&quot;?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No app passwords yet.</p>
{{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>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
const TOAST_AUTOHIDE_MS = 5000;
function armToastAutoDismiss(toastEl, bsToast) {
let timer = null;
const start = () => { timer = setTimeout(() => bsToast.hide(), TOAST_AUTOHIDE_MS); };
const stop = () => { if (timer) { clearTimeout(timer); timer = null; } };
toastEl.addEventListener('mouseenter', stop);
toastEl.addEventListener('mouseleave', start);
start();
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) {
const toast = new bootstrap.Toast(el);
toast.show();
armToastAutoDismiss(el, toast);
});
});
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();
}
});
});
});
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const passkeyAddBtn = document.getElementById('passkey-add-btn');
if (passkeyAddBtn) {
passkeyAddBtn.addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const beginResp = await fetch('/webmail/account/passkey/begin', { method: 'POST' });
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey registration');
const options = await beginResp.json();
const publicKey = options.publicKey;
publicKey.challenge = b64urlToBuf(publicKey.challenge);
publicKey.user.id = b64urlToBuf(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
}
const cred = await navigator.credentials.create({ publicKey });
const body = {
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
attestationObject: bufToB64url(cred.response.attestationObject),
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
},
};
const finishResp = await fetch('/webmail/account/passkey/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Could not save passkey');
window.location.reload();
} catch (e) {
errEl.textContent = e.message || 'Passkey registration failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,45 @@
{{define "webmail_login.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in - 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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">Webmail</h4>
<p class="text-muted">Manage your mailbox account</p>
</div>
<div class="card">
<div class="card-body p-4">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<form method="POST" action="/webmail/login">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
<div class="form-text">This is your mailbox account password — not an app password.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-box-arrow-in-right me-1"></i>Sign in</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,113 @@
{{define "webmail_login_mfa.html"}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify it's you - 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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.login-card { max-width: 420px; margin: 0 auto; width: 100%; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
<h4 class="mt-2">Verify it's you</h4>
<p class="text-muted">One more step to finish signing in</p>
</div>
<div class="card">
<div class="card-body p-4">
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
<div id="passkey-error" class="alert alert-danger d-none"></div>
{{if .has_passkeys}}
<div class="d-grid mb-3">
<button type="button" class="btn btn-outline-primary" id="passkey-btn">
<i class="bi bi-fingerprint me-1"></i>Use a passkey / security key
</button>
</div>
{{if .totp_enabled}}<div class="text-center text-muted mb-3">or</div>{{end}}
{{end}}
{{if .totp_enabled}}
<form method="POST" action="/webmail/login/mfa">
<div class="mb-3">
<label for="code" class="form-label">6-digit authenticator code</label>
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary"><i class="bi bi-shield-check me-1"></i>Verify</button>
</div>
</form>
{{end}}
</div>
</div>
</div>
<script>
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const passkeyBtn = document.getElementById('passkey-btn');
if (passkeyBtn) {
passkeyBtn.addEventListener('click', async function() {
const errEl = document.getElementById('passkey-error');
errEl.classList.add('d-none');
try {
const beginResp = await fetch('/webmail/login/passkey/begin');
if (!beginResp.ok) throw new Error((await beginResp.json()).error || 'Could not start passkey login');
const options = await beginResp.json();
const publicKey = options.publicKey;
publicKey.challenge = b64urlToBuf(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(c => ({ ...c, id: b64urlToBuf(c.id) }));
}
const assertion = await navigator.credentials.get({ publicKey });
const body = {
id: assertion.id,
rawId: bufToB64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufToB64url(assertion.response.authenticatorData),
clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
signature: bufToB64url(assertion.response.signature),
userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null,
},
};
const finishResp = await fetch('/webmail/login/passkey/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!finishResp.ok) throw new Error((await finishResp.json()).error || 'Passkey verification failed');
window.location.href = '/webmail/';
} catch (e) {
errEl.textContent = e.message || 'Passkey login failed';
errEl.classList.remove('d-none');
}
});
}
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,45 @@
{{define "webmail_totp_setup.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>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">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-qr-code me-2"></i>Scan with your authenticator app</h5></div>
<div class="card-body text-center">
{{if .qr_data_uri}}
<img src="{{.qr_data_uri}}" alt="TOTP QR code" class="img-fluid mb-3" style="max-width: 256px; background: white; padding: 8px; border-radius: 8px;">
{{end}}
<p class="text-muted">Can't scan? Enter this key manually:</p>
<code class="d-block mb-4" style="word-break: break-all;">{{.secret}}</code>
<form method="POST" action="/webmail/account/totp/confirm" class="text-start">
<div class="mb-3">
<label for="code" class="form-label">Enter the 6-digit code from your app to confirm</label>
<input type="text" class="form-control" id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autofocus>
</div>
<div class="d-flex justify-content-between">
<a href="/webmail/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Confirm and enable</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}
+158
View File
@@ -0,0 +1,158 @@
package webui
import (
"bytes"
"encoding/base64"
"image/png"
"net/http"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// webmailDashboard is the mailbox owner's single self-service page: their own quota
// usage, password change, TOTP MFA enable/disable, registered passkeys, and app
// passwords for IMAP/SMTP clients — everything scoped to reusing the app-password
// CRUD already built for the admin-managed mailbox pages (db.ListAppPasswordsForMailbox
// etc.), just presented for self-service instead of admin management.
func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
pctFull := 0.0
if mbox.QuotaBytes > 0 {
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
}
// webmail_account.html is a standalone page (own <head>, no admin base.html/sidebar)
// so render() doesn't auto-populate flashes for it the way admin pages get — pop
// them explicitly here instead.
a.render(w, r, "webmail_account.html", M{
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
"flashes": popFlashes(w, r),
})
}
func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
current := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirm := r.FormValue("new_password_confirm")
if !db.CheckPassword(current, mbox.PasswordHash) {
setFlash(w, "error", "Current password is incorrect")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if !isStrongPassword(newPassword) {
setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if newPassword != confirm {
setFlash(w, "error", "New passwords don't match")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if err := a.DB.SetMailboxPasswordHash(mbox.ID, hash); err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
setFlash(w, "success", "Password updated")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
key, err := totp.Generate(totp.GenerateOpts{Issuer: "mailgoserver", AccountName: mbox.Email})
if err != nil {
setFlash(w, "error", "Could not generate a TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, key.Secret(), false); err != nil {
setFlash(w, "error", "Could not save the TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
img, err := key.Image(256, 256)
qrDataURI := ""
if err == nil {
var buf bytes.Buffer
if png.Encode(&buf, img) == nil {
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
}
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
}
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
code := strings.TrimSpace(r.FormValue("code"))
if mbox.TOTPSecret == "" || !totp.Validate(code, mbox.TOTPSecret) {
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, mbox.TOTPSecret, true); err != nil {
setFlash(w, "error", "Something went wrong enabling MFA")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
// self-service — same generation/storage, just reached from the mailbox's own portal
// instead of an admin managing it on their behalf.
func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
label := strings.TrimSpace(r.FormValue("label"))
if label == "" {
label = "App password"
}
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
secret := db.GenerateAppPassword(minLen)
hash, err := db.HashPassword(secret)
if err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash); err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
pwID := int64(atoi(r.PathValue("pw_id")))
if err := a.DB.RemoveAppPassword(pwID, mbox.ID); err != nil {
setFlash(w, "error", "Error revoking app password")
} else {
setFlash(w, "success", "App password revoked")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
+94
View File
@@ -0,0 +1,94 @@
package webui
import (
"context"
"net/http"
"time"
"mailgoserver/internal/db"
)
// MailboxPrefix is the self-service webmail portal's URL prefix — a mailbox owner's
// login/account area, entirely separate from the admin dashboard at Prefix.
const MailboxPrefix = "/webmail"
const mailboxSessionCookieName = "mailgoserver_mailbox_session"
// mailboxCtxKey is its own type (not webui's ctxKey) so a mailbox session can never
// collide with or be confused for an admin session in request context — the two
// actor types are deliberately kept fully separate, per the parallel-schema design.
type mailboxCtxKey int
const ctxMailboxKey mailboxCtxKey = iota
func setMailboxSessionCookie(w http.ResponseWriter, token string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: mailboxSessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func clearMailboxSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: mailboxSessionCookieName, Value: "", Path: "/", MaxAge: -1})
}
// currentMailboxSession loads the session + mailbox for the request's cookie, if any
// and valid. A nil session/mailbox (no error) means "not logged in".
func (a *App) currentMailboxSession(r *http.Request) (*db.MailboxSession, *db.Mailbox, error) {
c, err := r.Cookie(mailboxSessionCookieName)
if err != nil || c.Value == "" {
return nil, nil, nil
}
sess, err := a.DB.GetMailboxSession(c.Value)
if err != nil || sess == nil {
return nil, nil, err
}
if time.Now().After(sess.ExpiresAt) {
_ = a.DB.DeleteMailboxSession(sess.Token)
return nil, nil, nil
}
mbox, err := a.DB.GetMailboxByID(sess.MailboxID)
if err != nil || mbox == nil {
return nil, nil, err
}
return sess, mbox, nil
}
func mailboxFromContext(r *http.Request) *db.Mailbox {
m, _ := r.Context().Value(ctxMailboxKey).(*db.Mailbox)
return m
}
// requireMailboxAuth gates every webmail route behind a valid, fully-authenticated
// mailbox session: logged in, and second factor satisfied if one is enabled.
func (a *App) requireMailboxAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, mbox, err := a.currentMailboxSession(r)
if err != nil {
a.Logger.Error("mailbox session lookup: %v", err)
}
if sess == nil || mbox == nil {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
needsMFA := mbox.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
needsMFA = true
}
}
if needsMFA && !sess.MFAVerified {
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+138
View File
@@ -0,0 +1,138 @@
package webui
import (
"net/http"
"strconv"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// mailboxPendingMFACookieName mirrors pendingMFACookieName for the mailbox portal —
// kept fully separate so an unfinished mailbox login can never be confused with (or
// promoted into) an admin session, and vice versa.
const mailboxPendingMFACookieName = "mailgoserver_mailbox_pending_mfa"
func setMailboxPendingMFACookie(w http.ResponseWriter, mailboxID string) {
http.SetCookie(w, &http.Cookie{
Name: mailboxPendingMFACookieName, Value: mailboxID, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: 10 * 60,
})
}
func clearMailboxPendingMFACookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: mailboxPendingMFACookieName, Value: "", Path: "/", MaxAge: -1})
}
func pendingMailboxMFAID(r *http.Request) int64 {
c, err := r.Cookie(mailboxPendingMFACookieName)
if err != nil {
return 0
}
return int64(atoi(c.Value))
}
func (a *App) webmailLoginForm(w http.ResponseWriter, r *http.Request) {
if sess, mbox, _ := a.currentMailboxSession(r); sess != nil && mbox != nil {
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
a.render(w, r, "webmail_login.html", M{})
}
// webmailLoginSubmit checks email+password against the mailbox's own portal
// password (never an app password — that's for IMAP/SMTP clients only).
func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
fail := func(msg string) {
a.render(w, r, "webmail_login.html", M{"error": msg, "email": email})
}
mbox, err := a.DB.GetMailboxByEmail(email)
if err != nil {
a.Logger.Error("webmail login lookup: %v", err)
fail("Something went wrong. Try again.")
return
}
if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) {
fail("Incorrect email or password.")
return
}
needsMFA := mbox.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 {
needsMFA = true
}
}
if !needsMFA {
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
if err != nil {
fail("Something went wrong. Try again.")
return
}
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
setMailboxPendingMFACookie(w, strconv.FormatInt(mbox.ID, 10))
http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound)
}
func (a *App) webmailMFAForm(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
mbox, _ := a.DB.GetMailboxByID(mailboxID)
if mbox == nil {
clearMailboxPendingMFACookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0})
}
func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
mbox, err := a.DB.GetMailboxByID(mailboxID)
if err != nil || mbox == nil {
clearMailboxPendingMFACookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) {
hasPasskeys, _ := a.DB.CountMailboxWebAuthnCredentials(mailboxID)
a.render(w, r, "webmail_login_mfa.html", M{"totp_enabled": mbox.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code."})
return
}
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
if err != nil {
a.Logger.Error("create mailbox session: %v", err)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
func (a *App) webmailLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
_ = a.DB.DeleteMailboxSession(c.Value)
}
clearMailboxSessionCookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
}
+254
View File
@@ -0,0 +1,254 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
// createTestMailboxWithPassword mirrors createMailboxFor but with a known plaintext
// portal password, for webmail login tests.
func createTestMailboxWithPassword(t *testing.T, app *App, email string, domainID int64, password string) int64 {
t.Helper()
hash, err := db.HashPassword(password)
if err != nil {
t.Fatal(err)
}
dek := mailstore.GenerateDEK()
wrapped, nonce, err := app.Mailstore.WrapDEK(dek)
if err != nil {
t.Fatal(err)
}
id, err := app.DB.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
if err != nil {
t.Fatal(err)
}
return id
}
func TestWebmailLoginSucceedsAndReachesDashboard(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "portaluser@example.com", domains[0].ID, "portal-password-123!")
_ = mailboxID
form := url.Values{"email": {"portaluser@example.com"}, "password": {"portal-password-123!"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected redirect after login, got %d: %s", rec.Code, rec.Body.String())
}
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == mailboxSessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("expected a mailbox session cookie to be set")
}
req2 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
req2.AddCookie(sessionCookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("expected dashboard to render, got %d: %s", rec2.Code, rec2.Body.String())
}
if !strings.Contains(rec2.Body.String(), "portaluser@example.com") {
t.Fatal("expected the dashboard to show the mailbox's own email")
}
}
func TestWebmailLoginRejectsAppPassword(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "portaluser2@example.com", domains[0].ID, "portal-password-123!")
appPwHash, err := db.HashPassword("an-app-password-not-the-portal-one")
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash); err != nil {
t.Fatal(err)
}
form := url.Values{"email": {"portaluser2@example.com"}, "password": {"an-app-password-not-the-portal-one"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Incorrect") {
t.Fatalf("expected login to reject an app password (portal login only accepts the portal password), got status %d: %s", rec.Code, rec.Body.String())
}
}
func TestWebmailUnauthenticatedRedirectsToLogin(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d", rec.Code)
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, MailboxPrefix+"/login") {
t.Fatalf("expected redirect to webmail login, got %q", loc)
}
}
// TestWebmailAndAdminSessionsAreIsolated confirms the two session systems really are
// separate: an admin session cookie doesn't grant webmail access and vice versa.
func TestWebmailAndAdminSessionsAreIsolated(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
createTestMailboxWithPassword(t, app, "portaluser3@example.com", domains[0].ID, "portal-password-123!")
adminCookie := loginSession(t, app)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/", nil)
req.AddCookie(&http.Cookie{Name: mailboxSessionCookieName, Value: adminCookie.Value})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected an admin session token to NOT grant webmail access, got status %d", rec.Code)
}
form := url.Values{"email": {"portaluser3@example.com"}, "password": {"portal-password-123!"}}
loginReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
mux.ServeHTTP(loginRec, loginReq)
var mailboxCookie *http.Cookie
for _, c := range loginRec.Result().Cookies() {
if c.Name == mailboxSessionCookieName {
mailboxCookie = c
}
}
if mailboxCookie == nil {
t.Fatal("expected a mailbox session cookie")
}
req2 := httptest.NewRequest(http.MethodGet, Prefix+"/domains", nil)
req2.AddCookie(&http.Cookie{Name: sessionCookieName, Value: mailboxCookie.Value})
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusFound {
t.Fatalf("expected a mailbox session token to NOT grant admin access, got status %d", rec2.Code)
}
}
func TestWebmailChangePassword(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "portaluser4@example.com", domains[0].ID, "old-password-123!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{
"current_password": {"old-password-123!"},
"new_password": {"new-password-456!"},
"new_password_confirm": {"new-password-456!"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", 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("expected redirect after password change, got %d: %s", rec.Code, rec.Body.String())
}
mbox, err := app.DB.GetMailboxByID(mailboxID)
if err != nil {
t.Fatal(err)
}
if !db.CheckPassword("new-password-456!", mbox.PasswordHash) {
t.Fatal("expected the new password to have been saved")
}
}
func TestWebmailAppPasswordSelfService(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "portaluser5@example.com", domains[0].ID, "password-123!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{"label": {"my laptop"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/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("expected redirect after creating app password, got %d: %s", rec.Code, rec.Body.String())
}
passwords, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
if err != nil || len(passwords) != 1 {
t.Fatalf("expected exactly 1 app password, got %d (err=%v)", len(passwords), err)
}
revokeReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/apppasswords/"+itoa(passwords[0].ID)+"/revoke", nil)
revokeReq.AddCookie(cookie)
revokeRec := httptest.NewRecorder()
mux.ServeHTTP(revokeRec, revokeReq)
if revokeRec.Code != http.StatusFound {
t.Fatalf("expected redirect after revoking, got %d", revokeRec.Code)
}
remaining, err := app.DB.ListAppPasswordsForMailbox(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected 0 app passwords after revoke, got %d (err=%v)", len(remaining), err)
}
}
func TestWebmailMFAGateRequiresCodeAfterTOTPEnabled(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "portaluser6@example.com", domains[0].ID, "password-123!")
if err := app.DB.SetMailboxTOTPSecret(mailboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
form := url.Values{"email": {"portaluser6@example.com"}, "password": {"password-123!"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected redirect to MFA step, got %d", rec.Code)
}
loc := rec.Header().Get("Location")
if loc != MailboxPrefix+"/login/mfa" {
t.Fatalf("expected redirect to %s, got %q", MailboxPrefix+"/login/mfa", loc)
}
// No fully-verified session cookie should exist yet — only the pending-MFA cookie.
for _, c := range rec.Result().Cookies() {
if c.Name == mailboxSessionCookieName {
t.Fatal("a fully-verified session must not be issued before MFA is satisfied")
}
}
}
// webmailLoginSession creates a fully-verified (no MFA enrolled) mailbox session
// directly via the DB, mirroring loginSession's admin equivalent.
func webmailLoginSession(t *testing.T, app *App, mailboxID int64) *http.Cookie {
t.Helper()
token, err := app.DB.CreateMailboxSession(mailboxID, true, sessionTTL)
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: mailboxSessionCookieName, Value: token}
}
+232
View File
@@ -0,0 +1,232 @@
package webui
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"github.com/go-webauthn/webauthn/webauthn"
"mailgoserver/internal/db"
)
// mailboxWebauthnSessionCookie mirrors webauthnSessionCookie but kept separate so an
// in-progress admin passkey ceremony and an in-progress mailbox one (e.g. different
// browser tabs) can never collide.
const mailboxWebauthnSessionCookie = "mailgoserver_mailbox_webauthn_session"
// mailboxWebauthnUser adapts a Mailbox + its stored credentials to webauthn.User,
// mirroring webauthnUser.
type mailboxWebauthnUser struct {
mailbox *db.Mailbox
creds []db.MailboxWebAuthnCredential
}
func (u *mailboxWebauthnUser) WebAuthnID() []byte {
sum := sha256.Sum256([]byte("mailbox-" + strconv.FormatInt(u.mailbox.ID, 10)))
return sum[:]
}
func (u *mailboxWebauthnUser) WebAuthnName() string { return u.mailbox.Email }
func (u *mailboxWebauthnUser) WebAuthnDisplayName() string { return u.mailbox.Email }
func (u *mailboxWebauthnUser) WebAuthnCredentials() []webauthn.Credential {
out := make([]webauthn.Credential, 0, len(u.creds))
for _, c := range u.creds {
var cred webauthn.Credential
if err := json.Unmarshal([]byte(c.CredentialData), &cred); err == nil {
out = append(out, cred)
}
}
return out
}
func (a *App) mailboxWebauthnUserFor(mbox *db.Mailbox) (*mailboxWebauthnUser, error) {
creds, err := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
if err != nil {
return nil, err
}
return &mailboxWebauthnUser{mailbox: mbox, creds: creds}, nil
}
func saveMailboxWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: mailboxWebauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b),
Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60,
})
return nil
}
func loadMailboxWebauthnSession(r *http.Request) (*webauthn.SessionData, error) {
c, err := r.Cookie(mailboxWebauthnSessionCookie)
if err != nil {
return nil, err
}
raw, err := base64.URLEncoding.DecodeString(c.Value)
if err != nil {
return nil, err
}
var s webauthn.SessionData
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
return &s, nil
}
func clearMailboxWebauthnSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: mailboxWebauthnSessionCookie, Value: "", Path: "/", MaxAge: -1})
}
func (a *App) webmailPasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
wa, err := a.buildWebAuthn()
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "WebAuthn is not configured correctly: " + err.Error()})
return
}
wu, err := a.mailboxWebauthnUserFor(mbox)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
return
}
creation, session, err := wa.BeginRegistration(wu)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
if err := saveMailboxWebauthnSession(w, session); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"})
return
}
writeJSON(w, http.StatusOK, creation)
}
func (a *App) webmailPasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
wa, err := a.buildWebAuthn()
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
session, err := loadMailboxWebauthnSession(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"})
return
}
wu, err := a.mailboxWebauthnUserFor(mbox)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
return
}
cred, err := wa.FinishRegistration(wu, *session, r)
clearMailboxWebauthnSession(w)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"error": err.Error()})
return
}
data, err := json.Marshal(cred)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
return
}
name := r.URL.Query().Get("name")
if name == "" {
name = "Passkey"
}
if err := a.DB.CreateMailboxWebAuthnCredential(mbox.ID, name, base64.URLEncoding.EncodeToString(cred.ID), string(data)); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
return
}
writeJSON(w, http.StatusOK, M{"success": true})
}
func (a *App) webmailPasskeyRemove(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := a.DB.DeleteMailboxWebAuthnCredential(pathID(r), mbox.ID); err != nil {
setFlash(w, "error", "Could not remove passkey")
} else {
setFlash(w, "success", "Passkey removed")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
}
// webmailPasskeyLoginBegin starts the passkey ceremony for the mailbox that's already
// passed its password and is now at the MFA step.
func (a *App) webmailPasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
mbox, err := a.DB.GetMailboxByID(mailboxID)
if err != nil || mbox == nil {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
wa, err := a.buildWebAuthn()
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
wu, err := a.mailboxWebauthnUserFor(mbox)
if err != nil || len(wu.creds) == 0 {
writeJSON(w, http.StatusBadRequest, M{"error": "No passkeys registered"})
return
}
assertion, session, err := wa.BeginLogin(wu)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
if err := saveMailboxWebauthnSession(w, session); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"})
return
}
writeJSON(w, http.StatusOK, assertion)
}
func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
mbox, err := a.DB.GetMailboxByID(mailboxID)
if err != nil || mbox == nil {
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
return
}
wa, err := a.buildWebAuthn()
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
return
}
session, err := loadMailboxWebauthnSession(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"})
return
}
wu, err := a.mailboxWebauthnUserFor(mbox)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
return
}
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
clearMailboxWebauthnSession(w)
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
return
}
clearMailboxWebauthnSession(w)
token, err := a.DB.CreateMailboxSession(mbox.ID, true, sessionTTL)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
return
}
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
writeJSON(w, http.StatusOK, M{"success": true})
}
+55 -2
View File
@@ -9,8 +9,10 @@ import (
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/acmecert"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/toolbox"
)
@@ -21,6 +23,8 @@ const Prefix = "/pymta-manager"
type App struct {
DB *db.DB
DKIM *dkim.Manager
Mailstore *mailstore.Store
ACME *acmecert.Manager
Cfg *ini.File
ConfigPath string
Logger *toolbox.Logger
@@ -31,8 +35,8 @@ type App struct {
// New builds the web UI. Templates and static assets come from the embedded
// filesystem (embed.go), not disk, so no directory paths are needed for them.
func New(database *db.DB, dkimMgr *dkim.Manager, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool) (*App, error) {
a := &App{DB: database, DKIM: dkimMgr, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp}
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool) (*App, error) {
a := &App{DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp}
if err := a.loadTemplates(); err != nil {
return nil, err
}
@@ -87,6 +91,29 @@ func (a *App) Mux() *http.ServeMux {
outer.HandleFunc("POST "+Prefix+"/login/passkey/finish", a.passkeyLoginFinish)
outer.HandleFunc("POST "+Prefix+"/logout", a.logout)
// Self-service webmail portal — entirely separate prefix, session cookie, and
// context keys from the admin dashboard above (see webmail_auth.go).
outer.HandleFunc("GET "+MailboxPrefix+"/login", a.webmailLoginForm)
outer.HandleFunc("POST "+MailboxPrefix+"/login", a.webmailLoginSubmit)
outer.HandleFunc("GET "+MailboxPrefix+"/login/mfa", a.webmailMFAForm)
outer.HandleFunc("POST "+MailboxPrefix+"/login/mfa", a.webmailMFASubmit)
outer.HandleFunc("GET "+MailboxPrefix+"/login/passkey/begin", a.webmailPasskeyLoginBegin)
outer.HandleFunc("POST "+MailboxPrefix+"/login/passkey/finish", a.webmailPasskeyLoginFinish)
outer.HandleFunc("POST "+MailboxPrefix+"/logout", a.webmailLogout)
webmailMux := http.NewServeMux()
webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailDashboard)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/disable", a.webmailTOTPDisable)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/begin", a.webmailPasskeyRegisterBegin)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/finish", a.webmailPasskeyRegisterFinish)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/passkey/{id}/remove", a.webmailPasskeyRemove)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/add", a.webmailAddAppPassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/apppasswords/{pw_id}/revoke", a.webmailRevokeAppPassword)
outer.Handle(MailboxPrefix+"/", a.requireMailboxAuth(webmailMux))
mux := http.NewServeMux()
mux.HandleFunc("GET "+Prefix+"/", a.dashboard)
@@ -127,6 +154,27 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("GET "+Prefix+"/senders/{id}/edit", a.editSenderForm)
mux.HandleFunc("POST "+Prefix+"/senders/{id}/edit", a.editSender)
mux.HandleFunc("GET "+Prefix+"/mailboxes", a.mailboxesList)
mux.HandleFunc("GET "+Prefix+"/mailboxes/add", a.addMailboxForm)
mux.HandleFunc("POST "+Prefix+"/mailboxes/add", a.addMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/delete", a.disableMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/enable", a.enableMailbox)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/remove", a.removeMailbox)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/edit", a.editMailboxForm)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/edit", a.editMailbox)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/apppasswords", a.appPasswordsList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/add", a.addAppPassword)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/apppasswords/{pw_id}/revoke", a.revokeAppPassword)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/aliases", a.aliasesList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/add", a.addAlias)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/aliases/{alias_id}/remove", a.removeAlias)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/lists", a.listsPage)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/add", a.addAllowBlockEntry)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/lists/{entry_id}/remove", a.removeAllowBlockEntry)
mux.HandleFunc("GET "+Prefix+"/mailboxes/{id}/rules", a.rulesList)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/add", a.addRule)
mux.HandleFunc("POST "+Prefix+"/mailboxes/{id}/rules/{rule_id}/remove", a.removeRule)
mux.HandleFunc("GET "+Prefix+"/ips", a.ipsList)
mux.HandleFunc("GET "+Prefix+"/ips/add", a.addIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/add", a.addIP)
@@ -146,6 +194,11 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("POST "+Prefix+"/dkim/check_dns", a.checkDKIMDNS)
mux.HandleFunc("POST "+Prefix+"/dkim/check_spf", a.checkSPFDNS)
mux.HandleFunc("GET "+Prefix+"/letsencrypt", a.letsEncryptPage)
mux.HandleFunc("POST "+Prefix+"/letsencrypt/save", a.letsEncryptSave)
mux.HandleFunc("POST "+Prefix+"/letsencrypt/obtain", a.letsEncryptObtainNow)
mux.HandleFunc("POST "+Prefix+"/api/letsencrypt/upload_gcloud_key", a.uploadGCloudServiceAccount)
mux.HandleFunc("GET "+Prefix+"/logs", a.logs)
mux.HandleFunc("GET "+Prefix+"/settings", a.settingsPage)
+25 -3
View File
@@ -11,8 +11,10 @@ import (
"time"
"gopkg.in/ini.v1"
"mailgoserver/internal/acmecert"
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/toolbox"
)
@@ -72,6 +74,17 @@ func newTestApp(t *testing.T) *App {
t.Fatal(err)
}
mstore := mailstore.New(database, mailstore.GenerateDEK(), filepath.Join(dir, "mailstore"))
mdek := mailstore.GenerateDEK()
mwrapped, mnonce, err := mstore.WrapDEK(mdek)
if err != nil {
t.Fatal(err)
}
mboxID, err := database.CreateMailbox("inbox@example.com", hash, domainID, 5*1024*1024*1024, mwrapped, mnonce)
if err != nil {
t.Fatal(err)
}
cfg := ini.Empty()
serverSec, _ := cfg.NewSection("Server")
serverSec.NewKey("smtp_port", "4025")
@@ -96,17 +109,22 @@ func newTestApp(t *testing.T) *App {
dkimSec.NewKey("spf_server_ip", "192.168.1.1")
attSec, _ := cfg.NewSection("Attachments")
attSec.NewKey("attachments_path", filepath.Join(dir, "attachments"))
mailstoreSec, _ := cfg.NewSection("Mailstore")
mailstoreSec.NewKey("app_password_min_length", "25")
mailstoreSec.NewKey("spam_reject_score", "5")
configPath := filepath.Join(dir, "settings.ini")
cfg.SaveTo(configPath)
app, err := New(database, dkimMgr, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true })
acmeMgr := acmecert.New(cfg, filepath.Join(dir, "server.crt"), filepath.Join(dir, "server.key"), filepath.Join(dir, "acme"), nil, toolbox.GetLogger("test"))
app, err := New(database, dkimMgr, mstore, acmeMgr, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true })
if err != nil {
t.Fatalf("New: %v", err)
}
_ = senderID
_ = key
_ = mboxID
return app
}
@@ -136,11 +154,12 @@ func TestAllPagesRender(t *testing.T) {
domains, _ := app.DB.ListDomains()
senders, _ := app.DB.ListSenders()
mailboxes, _ := app.DB.ListMailboxes()
ips, _ := app.DB.ListWhitelistedIPs()
keys, _ := app.DB.ListActiveDKIMKeysWithDomain()
logs, _ := app.DB.ListEmailLogsPage(0, 10)
if len(domains) == 0 || len(senders) == 0 || len(ips) == 0 || len(keys) == 0 || len(logs) == 0 {
t.Fatalf("seed data missing: domains=%d senders=%d ips=%d keys=%d logs=%d", len(domains), len(senders), len(ips), len(keys), len(logs))
if len(domains) == 0 || len(senders) == 0 || len(mailboxes) == 0 || len(ips) == 0 || len(keys) == 0 || len(logs) == 0 {
t.Fatalf("seed data missing: domains=%d senders=%d mailboxes=%d ips=%d keys=%d logs=%d", len(domains), len(senders), len(mailboxes), len(ips), len(keys), len(logs))
}
pagesToCheck := []string{
@@ -148,10 +167,13 @@ func TestAllPagesRender(t *testing.T) {
"/account",
"/domains", "/domains/add", "/domains/" + itoa(domains[0].ID) + "/edit",
"/senders", "/senders/add", "/senders/" + itoa(senders[0].ID) + "/edit",
"/mailboxes", "/mailboxes/add", "/mailboxes/" + itoa(mailboxes[0].ID) + "/edit", "/mailboxes/" + itoa(mailboxes[0].ID) + "/apppasswords", "/mailboxes/" + itoa(mailboxes[0].ID) + "/aliases",
"/mailboxes/" + itoa(mailboxes[0].ID) + "/lists", "/mailboxes/" + itoa(mailboxes[0].ID) + "/rules",
"/ips", "/ips/add", "/ips/" + itoa(ips[0].ID) + "/edit",
"/dkim", "/dkim/" + itoa(keys[0].ID) + "/edit",
"/logs", "/logs?type=emails", "/logs?type=auth",
"/settings",
"/letsencrypt",
"/msg/content/" + itoa(logs[0].ID),
"/admins", "/admins/add",
}