added IMAP, LetsEncrypt, update layout
This commit is contained in:
+14
-28
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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 "{{.Label}}"? 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 "{{.Name}}"?"><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 "{{.Label}}"?"><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}}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user