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