updated mailbox app password

This commit is contained in:
2026-08-13 07:03:40 +01:00
parent 70fa1a5f2c
commit 70c05cc777
21 changed files with 618 additions and 33 deletions
+14 -7
View File
@@ -26,7 +26,7 @@ func GenerateAppPassword(minLen int) string {
}
func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword, error) {
rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at
rows, err := d.Query(`SELECT id, mailbox_id, label, password_hash, is_active, created_at, last_used_at, expires_at
FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? ORDER BY created_at`, mailboxID)
if err != nil {
return nil, err
@@ -36,8 +36,8 @@ func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword,
for rows.Next() {
var p MailboxAppPassword
var createdAt string
var lastUsedAt sql.NullString
if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt); err != nil {
var lastUsedAt, expiresAt sql.NullString
if err := rows.Scan(&p.ID, &p.MailboxID, &p.Label, &p.PasswordHash, &p.IsActive, &createdAt, &lastUsedAt, &expiresAt); err != nil {
return nil, err
}
p.CreatedAt, _ = parseTime(createdAt)
@@ -45,14 +45,20 @@ func (d *DB) ListAppPasswordsForMailbox(mailboxID int64) ([]MailboxAppPassword,
t, _ := parseTime(lastUsedAt.String)
p.LastUsedAt = &t
}
if expiresAt.Valid {
t, _ := parseTime(expiresAt.String)
p.ExpiresAt = &t
}
out = append(out, p)
}
return out, rows.Err()
}
func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash) VALUES (?, ?, ?)`,
mailboxID, label, passwordHash)
// CreateAppPassword inserts a new app password. expiresAt is nil for one that never
// expires (the default).
func (d *DB) CreateAppPassword(mailboxID int64, label, passwordHash string, expiresAt *time.Time) (int64, error) {
res, err := d.Exec(`INSERT INTO esrv_mailbox_app_passwords (mailbox_id, label, password_hash, expires_at) VALUES (?, ?, ?, ?)`,
mailboxID, label, passwordHash, expiresAt)
if err != nil {
return 0, err
}
@@ -67,7 +73,8 @@ func (d *DB) VerifyMailboxAppPassword(email, password string) (*Mailbox, error)
if err != nil || mbox == nil {
return nil, err
}
rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords WHERE mailbox_id = ? AND is_active = 1`, mbox.ID)
rows, err := d.Query(`SELECT id, password_hash FROM esrv_mailbox_app_passwords
WHERE mailbox_id = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > ?)`, mbox.ID, time.Now())
if err != nil {
return nil, err
}
+3 -1
View File
@@ -79,7 +79,8 @@ type MailboxFilterRule struct {
}
// MailboxAppPassword is the only credential an IMAP/SMTP client ever uses. Plaintext
// is shown once at creation and never stored.
// is shown once at creation and never stored. ExpiresAt is nil for a password that
// never expires (the default).
type MailboxAppPassword struct {
ID int64
MailboxID int64
@@ -88,6 +89,7 @@ type MailboxAppPassword struct {
IsActive bool
CreatedAt time.Time
LastUsedAt *time.Time
ExpiresAt *time.Time
}
// MailboxMessage is one stored message. CachedFrom/CachedSubject are plaintext by
+3 -1
View File
@@ -203,7 +203,8 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_app_passwords (
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
last_used_at DATETIME,
expires_at DATETIME
);
-- A mailbox's receive-only (or, with can_send_as, send-as too) alternate addresses.
@@ -284,6 +285,7 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_domains ADD COLUMN default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120`,
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailbox_app_passwords ADD COLUMN expires_at DATETIME`,
}
for _, stmt := range stmts {
db.Exec(stmt)
+2 -2
View File
@@ -50,7 +50,7 @@ func newTestMailboxWithAppPassword(t *testing.T) (client *imapclient.Client, mai
if err != nil {
t.Fatal(err)
}
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash); err != nil {
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
t.Fatal(err)
}
@@ -167,7 +167,7 @@ func TestIMAPListAndSelectAdditionalFolder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash); err != nil {
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
t.Fatal(err)
}
+32
View File
@@ -2,6 +2,7 @@ package smtpserver
import (
"bytes"
"encoding/base64"
"io"
"mime"
"mime/multipart"
@@ -84,6 +85,11 @@ func parseMessage(raw []byte) (*parsedMessage, error) {
break
}
data, _ := io.ReadAll(part)
// multipart.Reader auto-decodes quoted-printable transparently during Read,
// but not base64 (see the mime/multipart docs) — without this, a base64
// attachment/body part is stored/relayed-for-display as raw base64 text
// instead of its actual decoded bytes.
data = decodeContentTransferEncoding(part.Header.Get("Content-Transfer-Encoding"), data)
disp, dispParams, _ := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
partCT := part.Header.Get("Content-Type")
partMediaType, _, _ := mime.ParseMediaType(partCT)
@@ -107,3 +113,29 @@ func parseMessage(raw []byte) (*parsedMessage, error) {
out.BodyText = strings.TrimSpace(out.BodyText)
return out, nil
}
// decodeContentTransferEncoding decodes a MIME part's body per its
// Content-Transfer-Encoding when that isn't already handled transparently by
// multipart.Reader (which only auto-decodes quoted-printable). base64 bodies are
// wrapped at a fixed line length, so whitespace/newlines are stripped before
// decoding. Falls back to the raw bytes on a decode error or any other encoding
// (7bit/8bit/binary need no transform).
func decodeContentTransferEncoding(cte string, data []byte) []byte {
if !strings.EqualFold(strings.TrimSpace(cte), "base64") {
return data
}
cleaned := make([]byte, 0, len(data))
for _, b := range data {
switch b {
case ' ', '\t', '\r', '\n':
continue
default:
cleaned = append(cleaned, b)
}
}
decoded, err := base64.StdEncoding.DecodeString(string(cleaned))
if err != nil {
return data
}
return decoded
}
+79
View File
@@ -0,0 +1,79 @@
package smtpserver
import (
"strings"
"testing"
)
// TestParseMessageDecodesBase64Attachment guards against a bug where
// mime/multipart.Reader only auto-decodes quoted-printable (not base64), so a
// base64-encoded attachment part was stored/read back as raw base64 text instead of
// its actual decoded bytes.
func TestParseMessageDecodesBase64Attachment(t *testing.T) {
raw := "" +
"From: sender@example.com\r\n" +
"To: rcpt@example.com\r\n" +
"Subject: test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n" +
"hello\r\n" +
"--BOUND\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"LICENSE\"\r\n" +
"Content-Transfer-Encoding: BASE64\r\n" +
"\r\n" +
"SGVsbG8sIHdvcmxkIQ==\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("got %d attachments, want 1", len(parsed.Attachments))
}
got := string(parsed.Attachments[0].Data)
want := "Hello, world!"
if got != want {
t.Errorf("attachment data = %q, want %q (decoded from base64, not left as raw base64 text)", got, want)
}
}
// TestParseMessageLeavesNonBase64EncodingsAlone confirms 7bit/8bit/absent
// Content-Transfer-Encoding parts pass through unmodified.
func TestParseMessageLeavesNonBase64EncodingsAlone(t *testing.T) {
raw := "" +
"From: sender@example.com\r\n" +
"To: rcpt@example.com\r\n" +
"Subject: test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"\r\n" +
"hello\r\n" +
"--BOUND\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Disposition: attachment; filename=\"notes.txt\"\r\n" +
"Content-Transfer-Encoding: 7bit\r\n" +
"\r\n" +
"plain text content\r\n" +
"--BOUND--\r\n"
parsed, err := parseMessage([]byte(raw))
if err != nil {
t.Fatalf("parseMessage: %v", err)
}
if len(parsed.Attachments) != 1 {
t.Fatalf("got %d attachments, want 1", len(parsed.Attachments))
}
got := strings.TrimSpace(string(parsed.Attachments[0].Data))
if got != "plain text content" {
t.Errorf("attachment data = %q, want %q", got, "plain text content")
}
}
+16 -3
View File
@@ -47,12 +47,13 @@ func existingHeaders(content string) map[string]string {
lines := strings.Split(content, "\n")
out := map[string]string{}
var lastKey string
trackFold := false
for _, raw := range lines {
line := strings.TrimRight(raw, "\r")
if line == "" {
break
}
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && lastKey != "" {
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && trackFold {
out[lastKey] += " " + strings.TrimSpace(line)
continue
}
@@ -62,8 +63,20 @@ func existingHeaders(content string) map[string]string {
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
out[key] = val
lastKey = key
// Unconditional out[key]=val here would keep the *last* duplicate instead of
// the first (e.g. a client-supplied "Content-Type: text/html" sent alongside
// swaks/library-generated "Content-Type: multipart/mixed; boundary=..." for an
// attachment) — discarding the boundary and causing the raw multipart body,
// left untouched below, to be delivered under the wrong Content-Type entirely.
// mail.ReadMessage's Header.Get (used elsewhere, e.g. parseMessage) already
// takes the first occurrence of a duplicated header, so this matches that.
if _, exists := out[key]; !exists {
out[key] = val
lastKey = key
trackFold = true
} else {
trackFold = false
}
}
return out
}
+26
View File
@@ -37,6 +37,32 @@ func TestEnsureRequiredHeadersFixedOrder(t *testing.T) {
}
}
// TestEnsureRequiredHeadersKeepsFirstDuplicateContentType guards against a regression
// where a duplicated Content-Type header (e.g. swaks emitting its own
// "multipart/mixed; boundary=..." for an --attach message, followed by a
// user-supplied "--add-header Content-Type: text/html") had its *last* occurrence win
// instead of its first. Losing the multipart boundary here meant the raw multipart
// body — untouched by this rebuild — got delivered under a plain, non-multipart
// Content-Type, so the recipient's client rendered the boundary markers and base64
// attachment text as literal body content instead of a real attachment.
func TestEnsureRequiredHeadersKeepsFirstDuplicateContentType(t *testing.T) {
raw := "Subject: hi\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"BOUND\"\r\n" +
"Content-Type: text/html\r\n" +
"\r\n" +
"--BOUND\r\nContent-Type: text/plain\r\n\r\nbody\r\n--BOUND--\r\n"
out := ensureRequiredHeaders(raw, "msg123@host", []string{"rcpt@example.com"}, "from@example.com", nil)
headerBlock, _ := splitHeadersBody(out)
if !strings.Contains(headerBlock, `Content-Type: multipart/mixed; boundary="BOUND"`) {
t.Errorf("expected the first (multipart/boundary) Content-Type to win, got header block:\n%s", headerBlock)
}
if strings.Contains(headerBlock, "Content-Type: text/html") {
t.Errorf("the later duplicate Content-Type: text/html should have been dropped, got header block:\n%s", headerBlock)
}
}
func TestExtractMessageIDDoesNotCrashOnMalformedHeader(t *testing.T) {
// No "@" in the Message-ID value — the fixed bug: Python's original crashes
// here (UnboundLocalError); the Go port must fall back to a generated ID.
+3 -3
View File
@@ -61,7 +61,7 @@ func TestMailboxAppPasswordCanSendAsPrimaryButNotArbitraryAddress(t *testing.T)
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
@@ -104,7 +104,7 @@ func TestMailboxAppPasswordCanSendAsEnabledAlias(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
@@ -131,7 +131,7 @@ func TestMailboxAppPasswordCannotSendAsReceiveOnlyAlias(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash); err != nil {
if _, err := backend.DB.CreateAppPassword(mailboxID, "test", hash, nil); err != nil {
t.Fatal(err)
}
addr := startTestServer(t, backend)
+41
View File
@@ -47,3 +47,44 @@ func popFlashes(w http.ResponseWriter, r *http.Request) []Flash {
}
return flashes
}
// AppPasswordReveal carries a freshly-generated app password secret across the
// create->redirect hop, kept out of the toast-driven Flash system so it can render
// as its own centered modal (with a copy button) instead of a toast that's easy to
// miss and can't be copied without retyping.
type AppPasswordReveal struct {
Label string `json:"l"`
Secret string `json:"s"`
}
const appPasswordRevealCookieName = "app_pw_reveal"
// setAppPasswordReveal mirrors setFlash but for the one-time secret reveal.
func setAppPasswordReveal(w http.ResponseWriter, label, secret string) {
encoded, _ := json.Marshal(AppPasswordReveal{Label: label, Secret: secret})
http.SetCookie(w, &http.Cookie{
Name: appPasswordRevealCookieName,
Value: base64.URLEncoding.EncodeToString(encoded),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// popAppPasswordReveal mirrors popFlashes but for the one-time secret reveal.
func popAppPasswordReveal(w http.ResponseWriter, r *http.Request) *AppPasswordReveal {
c, err := r.Cookie(appPasswordRevealCookieName)
if err != nil || c.Value == "" {
return nil
}
http.SetCookie(w, &http.Cookie{Name: appPasswordRevealCookieName, Value: "", Path: "/", MaxAge: -1})
raw, err := base64.URLEncoding.DecodeString(c.Value)
if err != nil {
return nil
}
var reveal AppPasswordReveal
if err := json.Unmarshal(raw, &reveal); err != nil {
return nil
}
return &reveal
}
+57 -5
View File
@@ -1,8 +1,10 @@
package webui
import (
"fmt"
"net/http"
"strings"
"time"
"mailgoserver/internal/db"
)
@@ -18,12 +20,50 @@ func (a *App) appPasswordsList(w http.ResponseWriter, r *http.Request) {
if err != nil {
setFlash(w, "error", "Error loading app passwords")
}
a.render(w, r, "mailbox_apppasswords.html", M{"active": "mailboxes", "mailbox": mailbox, "passwords": passwords})
a.render(w, r, "mailbox_apppasswords.html", M{
"active": "mailboxes", "mailbox": mailbox, "passwords": passwords,
"reveal": popAppPasswordReveal(w, r),
})
}
// appPasswordExpiry turns the create form's preset select (plus an optional custom
// date) into an expiry timestamp. Returns (nil, nil) for "never expires", the default.
func appPasswordExpiry(preset, customDate string, loc *time.Location) (*time.Time, error) {
now := time.Now()
var t time.Time
switch preset {
case "", "never":
return nil, nil
case "1d":
t = now.Add(24 * time.Hour)
case "7d":
t = now.Add(7 * 24 * time.Hour)
case "30d":
t = now.Add(30 * 24 * time.Hour)
case "180d":
t = now.Add(180 * 24 * time.Hour)
case "365d":
t = now.Add(365 * 24 * time.Hour)
case "custom":
if customDate == "" {
return nil, fmt.Errorf("an expiration date is required")
}
d, err := time.ParseInLocation("2006-01-02", customDate, loc)
if err != nil {
return nil, fmt.Errorf("invalid expiration date")
}
// End of the chosen day, not midnight at its start, so the picked date is
// still valid for its whole duration.
t = d.Add(24*time.Hour - time.Second)
default:
return nil, fmt.Errorf("invalid expiration option")
}
return &t, nil
}
// addAppPassword generates a random secret (the only credential IMAP/SMTP clients ever
// use for this mailbox — never the portal password), shows it once via flash, and
// stores only its bcrypt hash.
// use for this mailbox — never the portal password), reveals it once via a one-time
// cookie the list page renders as a modal, and stores only its bcrypt hash.
func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
@@ -34,6 +74,18 @@ func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
label = "App password"
}
tzName := a.Cfg.Section("Server").Key("time_zone").MustString("UTC")
loc, err := time.LoadLocation(tzName)
if err != nil {
loc = time.UTC
}
expiresAt, err := appPasswordExpiry(r.FormValue("expires_preset"), r.FormValue("expires_custom"), loc)
if err != nil {
setFlash(w, "error", err.Error())
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
return
}
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
secret := db.GenerateAppPassword(minLen)
hash, err := db.HashPassword(secret)
@@ -42,12 +94,12 @@ func (a *App) addAppPassword(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash); err != nil {
if _, err := a.DB.CreateAppPassword(mailbox.ID, label, hash, expiresAt); err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
return
}
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
setAppPasswordReveal(w, label, secret)
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/apppasswords", http.StatusFound)
}
+139
View File
@@ -0,0 +1,139 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"mailgoserver/internal/db"
)
func TestAppPasswordExpiryPresets(t *testing.T) {
loc := time.UTC
now := time.Now()
t.Run("never expires by default", func(t *testing.T) {
got, err := appPasswordExpiry("", "", loc)
if err != nil || got != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("7 day preset", func(t *testing.T) {
got, err := appPasswordExpiry("7d", "", loc)
if err != nil || got == nil {
t.Fatalf("got (%v, %v), want a non-nil expiry", got, err)
}
wantAround := now.Add(7 * 24 * time.Hour)
if diff := got.Sub(wantAround); diff < -time.Minute || diff > time.Minute {
t.Fatalf("expiry %v not within a minute of %v", got, wantAround)
}
})
t.Run("custom date is end of day", func(t *testing.T) {
got, err := appPasswordExpiry("custom", "2030-01-15", loc)
if err != nil || got == nil {
t.Fatalf("got (%v, %v), want a non-nil expiry", got, err)
}
want := time.Date(2030, 1, 15, 23, 59, 59, 0, loc)
if !got.Equal(want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("custom without a date errors", func(t *testing.T) {
if _, err := appPasswordExpiry("custom", "", loc); err == nil {
t.Fatal("expected an error for a missing custom date")
}
})
t.Run("invalid preset errors", func(t *testing.T) {
if _, err := appPasswordExpiry("bogus", "", loc); err == nil {
t.Fatal("expected an error for an invalid preset")
}
})
}
// TestAddAppPasswordExpiredIsRejectedByAuth confirms an app password created with a
// past expiry (simulated directly at the DB layer, since the UI can only pick future
// dates) can no longer authenticate, even while still marked active.
func TestAddAppPasswordExpiredIsRejectedByAuth(t *testing.T) {
app := newTestApp(t)
mailboxes, _ := app.DB.ListMailboxes()
if len(mailboxes) == 0 {
t.Fatal("no seeded mailbox")
}
mbox := mailboxes[0].Mailbox
past := time.Now().Add(-time.Hour)
hash, err := db.HashPassword("some-secret-app-password-value")
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAppPassword(mbox.ID, "expired", hash, &past); err != nil {
t.Fatal(err)
}
got, err := app.DB.VerifyMailboxAppPassword(mbox.Email, "some-secret-app-password-value")
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatal("expired app password must not authenticate")
}
}
// TestAddAppPasswordSetsRevealCookieNotFlash confirms the create handler no longer
// puts the plaintext secret in the toast-driven Flash cookie (easy to miss, no copy
// button) and instead sets the dedicated one-time reveal cookie the list page renders
// as a modal.
func TestAddAppPasswordSetsRevealCookieNotFlash(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
mailboxes, _ := app.DB.ListMailboxes()
mboxID := mailboxes[0].ID
form := url.Values{"label": {"laptop"}, "expires_preset": {"never"}}
req := httptest.NewRequest(http.MethodPost, "/pymta-manager/mailboxes/"+itoa(mboxID)+"/apppasswords/add", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("add app password: status=%d body=%s", rec.Code, rec.Body.String())
}
var revealCookie, flashCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
switch c.Name {
case appPasswordRevealCookieName:
revealCookie = c
case flashCookieName:
flashCookie = c
}
}
if revealCookie == nil || revealCookie.Value == "" {
t.Fatal("expected a non-empty app password reveal cookie")
}
if flashCookie != nil && flashCookie.Value != "" {
t.Fatalf("flash cookie should not carry the secret, got %q", flashCookie.Value)
}
// Following the redirect (as the browser would) should render the reveal modal
// with the secret, and clear the one-time cookie.
req2 := httptest.NewRequest(http.MethodGet, "/pymta-manager/mailboxes/"+itoa(mboxID)+"/apppasswords", nil)
req2.AddCookie(cookie)
req2.AddCookie(revealCookie)
rec2 := httptest.NewRecorder()
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("apppasswords list: status=%d", rec2.Code)
}
if !strings.Contains(rec2.Body.String(), "appPasswordRevealModal") {
t.Fatal("expected the reveal modal markup in the response")
}
}
+1 -1
View File
@@ -149,7 +149,7 @@ func TestAppPasswordCannotBeRevokedFromAnotherMailbox(t *testing.T) {
mailboxA := createMailboxFor(t, app, "dave@"+domainA.DomainName, domainA.ID)
mailboxB := createMailboxFor(t, app, "carol@"+domainB.DomainName, domainB.ID)
pwID, err := app.DB.CreateAppPassword(mailboxB.ID, "carol's laptop", "irrelevant-hash")
pwID, err := app.DB.CreateAppPassword(mailboxB.ID, "carol's laptop", "irrelevant-hash", nil)
if err != nil {
t.Fatal(err)
}
+4
View File
@@ -40,6 +40,10 @@ func (a *App) funcMap() template.FuncMap {
}
return t.Format(pyToGoLayout(layout))
},
// isPast reports whether a nullable expiry timestamp has already passed —
// used to badge an app password as "Expired" even while is_active is
// still 1 (expiry and revocation are independent states).
"isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
"title": strings.Title,
"upper": strings.ToUpper,
"lower": strings.ToLower,
+6 -1
View File
@@ -46,7 +46,12 @@ func (a *App) settingsUpdate(w http.ResponseWriter, r *http.Request) {
for _, name := range a.Cfg.SectionStrings() {
sec := a.Cfg.Section(name)
for _, k := range sec.Keys() {
field := name + "." + k.Name()
// Form field names are always lowercase (matches settingsPage's
// strings.ToLower(k.Name()) template keys), but ini key names keep
// whatever case the defaults table used (e.g. "SMTP_PORT") — comparing
// k.Name() directly here meant every uppercase-defined key silently
// never saved.
field := name + "." + strings.ToLower(k.Name())
if !r.Form.Has(field) {
continue
}
+51
View File
@@ -0,0 +1,51 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"mailgoserver/internal/config"
)
// TestSettingsUpdateSavesUppercaseDefinedKeys guards against a regression where
// settingsUpdate compared the submitted form field against the ini key's exact
// stored case (e.g. "SMTP_PORT", as the defaults table defines it) instead of the
// lowercase name settings.html always submits — silently dropping every edit to a
// key whose default name isn't already lowercase.
func TestSettingsUpdateSavesUppercaseDefinedKeys(t *testing.T) {
app := newTestApp(t)
// Swap in a config produced the real way (config.Load + defaults), where keys
// like SMTP_PORT keep their defined uppercase name — unlike newTestApp's own
// hand-built, already-lowercase fixture cfg.
dir := t.TempDir()
configPath := filepath.Join(dir, "settings.ini")
realCfg, err := config.Load(configPath)
if err != nil {
t.Fatal(err)
}
app.Cfg = realCfg
app.ConfigPath = configPath
mux := app.Mux()
cookie := loginSession(t, app)
form := url.Values{"Server.smtp_port": {"2525"}}
req := httptest.NewRequest(http.MethodPost, "/pymta-manager/settings_update", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("settings_update: status=%d body=%s", rec.Code, rec.Body.String())
}
got := app.Cfg.Section("Server").Key("SMTP_PORT").Value()
if got != "2525" {
t.Fatalf("SMTP_PORT not updated: got %q, want %q", got, "2525")
}
}
@@ -4,11 +4,14 @@
{{define "content"}}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-key me-2"></i>App Passwords <small class="text-muted fs-6">{{.mailbox.Email}}</small></h2>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
<div class="d-flex gap-2">
<a href="/pymta-manager/mailboxes/{{.mailbox.ID}}/edit" class="btn btn-outline-warning"><i class="bi bi-shield-lock me-2"></i>Reset Mailbox Password</a>
<a href="/pymta-manager/mailboxes" class="btn btn-secondary"><i class="bi bi-arrow-left me-2"></i>Back to Mailboxes</a>
</div>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it.
<i class="bi bi-info-circle me-2"></i>Use an app password (never the mailbox's own password) to set this mailbox up in Thunderbird or any other IMAP/SMTP client. Each one is shown only once, right after you create it. To reset the mailbox owner's own login password (used for the self-service portal), use <strong>Reset Mailbox Password</strong> above.
</div>
<div class="card mb-4">
@@ -19,6 +22,22 @@
<label for="label" class="form-label">Label</label>
<input type="text" class="form-control" id="label" name="label" placeholder="e.g. Thunderbird laptop">
</div>
<div class="col-auto">
<label for="expires_preset" class="form-label">Expires</label>
<select class="form-select" id="expires_preset" name="expires_preset" onchange="toggleCustomExpiry()">
<option value="never" selected>Never</option>
<option value="1d">1 day</option>
<option value="7d">7 days</option>
<option value="30d">1 month</option>
<option value="180d">6 months</option>
<option value="365d">1 year</option>
<option value="custom">Custom date&hellip;</option>
</select>
</div>
<div class="col-auto" id="expires_custom_wrap" style="display: none;">
<label for="expires_custom" class="form-label">Expiration Date</label>
<input type="date" class="form-control" id="expires_custom" name="expires_custom">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success"><i class="bi bi-key me-2"></i>Generate</button>
</div>
@@ -32,13 +51,19 @@
{{if .passwords}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
<thead><tr><th>Label</th><th>Created</th><th>Last Used</th><th>Expires</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .passwords}}
<tr>
<td>{{.Label}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td><small class="text-muted">{{if .LastUsedAt}}{{strftime "%Y-%m-%d %H:%M" .LastUsedAt}}{{else}}Never{{end}}</small></td>
<td>
{{if .ExpiresAt}}
<small class="{{if isPast .ExpiresAt}}text-danger{{else}}text-muted{{end}}">{{strftime "%Y-%m-%d %H:%M" .ExpiresAt}}</small>
{{if isPast .ExpiresAt}}<span class="badge bg-danger ms-1">Expired</span>{{end}}
{{else}}<small class="text-muted">Never</small>{{end}}
</td>
<td>{{if .IsActive}}<span class="badge bg-success">Active</span>{{else}}<span class="badge bg-danger">Revoked</span>{{end}}</td>
<td>
<form method="post" action="/pymta-manager/mailboxes/{{$.mailbox.ID}}/apppasswords/{{.ID}}/revoke" class="d-inline">
@@ -59,4 +84,51 @@
{{end}}
</div>
</div>
{{if .reveal}}
<div class="modal fade" id="appPasswordRevealModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title"><i class="bi bi-key-fill me-2"></i>App Password Created</h5>
</div>
<div class="modal-body">
<p class="mb-2">Label: <strong>{{.reveal.Label}}</strong></p>
<p class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Copy this password now — it will not be shown again.</p>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="revealedAppPassword" value="{{.reveal.Secret}}" readonly onclick="this.select()">
<button class="btn btn-outline-light" type="button" onclick="copyRevealedAppPassword()"><i class="bi bi-clipboard me-1"></i>Copy</button>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
{{end}}
{{end}}
{{define "extra_js"}}
<script>
function toggleCustomExpiry() {
const preset = document.getElementById('expires_preset').value;
const wrap = document.getElementById('expires_custom_wrap');
wrap.style.display = preset === 'custom' ? '' : 'none';
document.getElementById('expires_custom').required = preset === 'custom';
}
{{if .reveal}}
document.addEventListener('DOMContentLoaded', function() {
new bootstrap.Modal(document.getElementById('appPasswordRevealModal')).show();
});
function copyRevealedAppPassword() {
const input = document.getElementById('revealedAppPassword');
input.select();
navigator.clipboard.writeText(input.value)
.then(() => showToast('Copied to clipboard', 'success'))
.catch(() => { document.execCommand('copy'); showToast('Copied to clipboard', 'success'); });
}
{{end}}
</script>
{{end}}
+55 -4
View File
@@ -31,6 +31,16 @@
<input type="number" class="form-control" name="Server.smtp_tls_port" value="{{.settings.Server.smtp_tls_port}}" min="1" max="65535">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Admin UI HTTP Port</label>
<div class="setting-description">Plain HTTP port for this admin web interface</div>
<input type="number" class="form-control" name="Server.web_http_port" value="{{.settings.Server.web_http_port}}" min="1" max="65535">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">Admin UI HTTPS Port</label>
<div class="setting-description">Self-signed by default, or the Let's Encrypt cert once enabled</div>
<input type="number" class="form-control" name="Server.web_https_port" value="{{.settings.Server.web_https_port}}" min="1" max="65535">
</div></div>
</div>
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">Bind IP Address</label>
<input type="text" class="form-control" name="Server.bind_ip" value="{{.settings.Server.bind_ip}}">
@@ -108,6 +118,47 @@
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-inbox me-2"></i>IMAP Configuration</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="row">
<div class="col-md-6"><div class="mb-3"><label class="form-label">IMAP Port</label>
<div class="setting-description">Plain IMAP port (no STARTTLS offered)</div>
<input type="number" class="form-control" name="IMAP.imap_port" value="{{.settings.IMAP.imap_port}}" min="1" max="65535">
</div></div>
<div class="col-md-6"><div class="mb-3"><label class="form-label">IMAP TLS Port</label>
<div class="setting-description">Implicit-TLS IMAP port (IMAPS)</div>
<input type="number" class="form-control" name="IMAP.imap_tls_port" value="{{.settings.IMAP.imap_tls_port}}" min="1" max="65535">
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-exclamation me-2"></i>Rspamd (Spam Scoring)</h5></div>
<div class="card-body">
<div class="setting-section">
<div class="setting-description">Optional; the built-in heuristic spam score always runs regardless of this setting.</div>
<div class="row">
<div class="col-md-4"><div class="mb-3"><label class="form-label">Enabled</label>
<select class="form-select" name="Rspamd.enabled">
<option value="true" {{if eq .settings.Rspamd.enabled "true"}}selected{{end}}>Yes</option>
<option value="false" {{if eq .settings.Rspamd.enabled "false"}}selected{{end}}>No</option>
</select>
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">Rspamd URL</label>
<input type="text" class="form-control" name="Rspamd.url" value="{{.settings.Rspamd.url}}" placeholder="http://127.0.0.1:11333">
</div></div>
<div class="col-md-4"><div class="mb-3"><label class="form-label">Reject Score</label>
<input type="number" class="form-control" name="Rspamd.reject_score" value="{{.settings.Rspamd.reject_score}}" min="1">
</div></div>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>TLS/SSL Configuration</h5></div>
<div class="card-body">
@@ -201,15 +252,15 @@
}
document.querySelector('form').addEventListener('submit', function(e) {
const ports = ['Server.smtp_port', 'Server.smtp_tls_port'];
const ports = ['Server.smtp_port', 'Server.smtp_tls_port', 'Server.web_http_port', 'Server.web_https_port', 'IMAP.imap_port', 'IMAP.imap_tls_port'];
const seen = {};
for (const portField of ports) {
const input = document.querySelector(`[name="${portField}"]`);
const port = parseInt(input.value);
if (port < 1 || port > 65535) { e.preventDefault(); showToast(`Invalid port number: ${port}.`, 'danger'); input.focus(); return; }
if (seen[port]) { e.preventDefault(); showToast(`Port ${port} is used more than once — each port must be different.`, 'danger'); input.focus(); return; }
seen[port] = true;
}
const smtpPort = document.querySelector('[name="Server.smtp_port"]').value;
const tlsPort = document.querySelector('[name="Server.smtp_tls_port"]').value;
if (smtpPort === tlsPort) { e.preventDefault(); showToast('SMTP and TLS ports must be different.', 'danger'); return; }
const serverBanner = document.querySelector('[name="Server.server_banner"]');
if (serverBanner && !serverBanner.value.trim()) { serverBanner.value = '""'; }
+1 -1
View File
@@ -137,7 +137,7 @@ func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash); err != nil {
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash, nil); err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
+1 -1
View File
@@ -78,7 +78,7 @@ func TestWebmailLoginRejectsAppPassword(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash); err != nil {
if _, err := app.DB.CreateAppPassword(mailboxID, "test", appPwHash, nil); err != nil {
t.Fatal(err)
}
+9
View File
@@ -89,11 +89,20 @@ func newTestApp(t *testing.T) *App {
serverSec, _ := cfg.NewSection("Server")
serverSec.NewKey("smtp_port", "4025")
serverSec.NewKey("smtp_tls_port", "40465")
serverSec.NewKey("web_http_port", "5000")
serverSec.NewKey("web_https_port", "5001")
serverSec.NewKey("bind_ip", "0.0.0.0")
serverSec.NewKey("time_zone", "UTC")
serverSec.NewKey("hostname", "mail.example.com")
serverSec.NewKey("helo_hostname", "mail.example.com")
serverSec.NewKey("server_banner", "")
imapSec, _ := cfg.NewSection("IMAP")
imapSec.NewKey("imap_port", "1143")
imapSec.NewKey("imap_tls_port", "1993")
rspamdSec, _ := cfg.NewSection("Rspamd")
rspamdSec.NewKey("enabled", "false")
rspamdSec.NewKey("url", "http://127.0.0.1:11333")
rspamdSec.NewKey("reject_score", "15")
dbSec, _ := cfg.NewSection("Database")
dbSec.NewKey("database_url", "sqlite:///server_data/smtp_server.db")
logSec, _ := cfg.NewSection("Logging")