package webui import ( "fmt" "net/http" "strings" "time" "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, "reveal": popAppPasswordReveal(w, r), }) } // appPasswordExpiry turns the create form's preset select (plus an optional custom // date) into an expiry timestamp. Returns (nil, nil) for "never expires", the default. func appPasswordExpiry(preset, customDate string, loc *time.Location) (*time.Time, error) { now := time.Now() var t time.Time switch preset { case "", "never": return nil, nil case "1d": t = now.Add(24 * time.Hour) case "7d": t = now.Add(7 * 24 * time.Hour) case "30d": t = now.Add(30 * 24 * time.Hour) case "180d": t = now.Add(180 * 24 * time.Hour) case "365d": t = now.Add(365 * 24 * time.Hour) case "custom": if customDate == "" { return nil, fmt.Errorf("an expiration date is required") } d, err := time.ParseInLocation("2006-01-02", customDate, loc) if err != nil { return nil, fmt.Errorf("invalid expiration date") } // End of the chosen day, not midnight at its start, so the picked date is // still valid for its whole duration. t = d.Add(24*time.Hour - time.Second) default: return nil, fmt.Errorf("invalid expiration option") } return &t, nil } // addAppPassword generates a random secret (the only credential IMAP/SMTP clients ever // use for this mailbox — never the portal password), reveals it once via a one-time // cookie the list page renders as a modal, and stores only its bcrypt hash. func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) { mailbox, ok := a.mailboxWithAccess(w, r) if !ok { return } label := strings.TrimSpace(r.FormValue("label")) if label == "" { label = "App password" } tzName := a.Cfg.Section("Server").Key("time_zone").MustString("UTC") loc, err := time.LoadLocation(tzName) if err != nil { loc = time.UTC } expiresAt, err := appPasswordExpiry(r.FormValue("expires_preset"), r.FormValue("expires_custom"), loc) if err != nil { setFlash(w, "error", err.Error()) http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound) return } minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25) secret := db.GenerateAppPassword(minLen) hash, err := db.HashPassword(secret) 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, expiresAt); err != nil { setFlash(w, "error", "Error creating app password") http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound) return } setAppPasswordReveal(w, label, 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") }