MFA fix, added IP blacklist, update webmail client

This commit is contained in:
2026-08-14 13:04:55 +01:00
parent 6063f95504
commit 892f366a16
122 changed files with 13362 additions and 251 deletions
+8 -3
View File
@@ -3,6 +3,7 @@ package webui
import (
"bytes"
"encoding/base64"
"html/template"
"image/png"
"net/http"
"strings"
@@ -96,7 +97,11 @@ func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) {
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
}
a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
// html/template's URL-context escaper only allows http/https/mailto schemes for a
// plain string in a src="..." attribute — anything else, including data: URIs,
// gets silently replaced with "#ZgotmplZ" (confirmed live). template.URL marks
// this value as pre-approved so the actual QR image renders instead of nothing.
a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)})
}
// totpSetupConfirm verifies a code against the pending secret and, if correct, flips
@@ -114,7 +119,7 @@ func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator enabled")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator enabled")
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
}
@@ -124,7 +129,7 @@ func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DisableAdminTOTP(user.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "TOTP authenticator disabled")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator disabled")
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
+1 -1
View File
@@ -232,7 +232,7 @@ func (a *App) resetAdminMFA(w http.ResponseWriter, r *http.Request) {
if err := a.DB.ResetAdminMFA(target.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", target.Username, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
_ = a.DB.LogAuthAttempt("admin_mfa", target.Username, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
setFlash(w, "success", "MFA reset for "+target.Username)
}
http.Redirect(w, r, Prefix+"/admins", http.StatusFound)
+106
View File
@@ -0,0 +1,106 @@
package webui
import (
"net"
"net/http"
)
// blacklistPage lists both currently/recently blacklisted IPs (auto or manual) and the
// abuse-detection whitelist — global-admin only, since a blacklist entry isn't
// attributable to one domain the way a mailbox or relay-whitelist row is.
func (a *App) blacklistPage(w http.ResponseWriter, r *http.Request) {
entries, err := a.DB.ListBlacklist()
if err != nil {
setFlash(w, "error", "Error loading blacklist")
}
whitelist, err := a.DB.ListAbuseWhitelist()
if err != nil {
setFlash(w, "error", "Error loading abuse whitelist")
}
a.render(w, r, "blacklist.html", M{"active": "blacklist", "entries": entries, "whitelist": whitelist})
}
// addBlacklistEntry is an admin-initiated manual block: fixed duration, no escalation.
func (a *App) addBlacklistEntry(w http.ResponseWriter, r *http.Request) {
ip := r.FormValue("ip_address")
reason := r.FormValue("reason")
hours := atoi(r.FormValue("hours"))
if net.ParseIP(ip) == nil || hours <= 0 {
setFlash(w, "error", "A valid IP address and a positive duration in hours are required")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
if err := a.DB.AddManualBlacklistEntry(ip, reason, hours); err != nil {
setFlash(w, "error", "Error blacklisting IP")
} else {
setFlash(w, "success", "IP blacklisted")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) removeBlacklistEntry(w http.ResponseWriter, r *http.Request) {
if err := a.DB.RemoveBlacklistEntry(pathID(r)); err != nil {
setFlash(w, "error", "Error removing blacklist entry")
} else {
setFlash(w, "success", "Blacklist entry removed")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
// whitelistBlacklistedIP removes ip from the blacklist and adds it to the abuse
// whitelist in one action, so an admin can undo a false-positive auto-block without
// two separate trips.
func (a *App) whitelistBlacklistedIP(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
entries, err := a.DB.ListBlacklist()
if err != nil {
setFlash(w, "error", "Error loading blacklist")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
var ip string
for _, e := range entries {
if e.ID == id {
ip = e.IPAddress
break
}
}
if ip == "" {
http.NotFound(w, r)
return
}
if err := a.DB.AddAbuseWhitelist(ip, "whitelisted from a blacklist entry"); err != nil {
setFlash(w, "error", "Error whitelisting IP")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
_ = a.DB.RemoveBlacklistEntry(id)
setFlash(w, "success", ip+" whitelisted and removed from the blacklist")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) addAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) {
ip := r.FormValue("ip_address")
note := r.FormValue("note")
if net.ParseIP(ip) == nil {
setFlash(w, "error", "A valid IP address is required")
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
return
}
if err := a.DB.AddAbuseWhitelist(ip, note); err != nil {
setFlash(w, "error", "Error adding to abuse whitelist")
} else {
setFlash(w, "success", "IP added to the abuse-detection whitelist")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
func (a *App) removeAbuseWhitelistEntry(w http.ResponseWriter, r *http.Request) {
if err := a.DB.RemoveAbuseWhitelist(pathID(r)); err != nil {
setFlash(w, "error", "Error removing abuse whitelist entry")
} else {
setFlash(w, "success", "Removed from the abuse-detection whitelist")
}
http.Redirect(w, r, Prefix+"/blacklist", http.StatusFound)
}
+83
View File
@@ -0,0 +1,83 @@
package webui
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
)
// csrfSessionCookieNames are tried in order to find whatever session-identifying
// cookie the current request carries — the full admin session, the full mailbox
// session, or either's pending-MFA cookie (covers the MFA step of login, which
// happens before the full session exists but after a pending cookie is set). The
// bare initial login POST, before any of these cookies exist yet, has no source
// token and so is intentionally not CSRF-checked — standard practice, since there's
// no session yet for a forged request to act against.
var csrfSessionCookieNames = []string{
sessionCookieName, mailboxSessionCookieName, pendingMFACookieName, mailboxPendingMFACookieName,
}
func csrfSourceToken(r *http.Request) (string, bool) {
for _, name := range csrfSessionCookieNames {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return c.Value, true
}
}
return "", false
}
// csrfTokenFor derives this request's expected CSRF token: an HMAC over whatever
// session-identifying cookie is present, keyed by the app secret. Deterministic and
// unstored — recomputed fresh on both render (render.go injects it into every page)
// and validation (CSRFProtect below), so there's no server-side token table to
// manage or expire.
func (a *App) csrfTokenFor(r *http.Request) string {
token, ok := csrfSourceToken(r)
if !ok {
return ""
}
mac := hmac.New(sha256.New, a.appSecret)
mac.Write([]byte(token))
return hex.EncodeToString(mac.Sum(nil))
}
// csrfProtectedMethods are the only ones CSRFProtect checks — GET/HEAD/OPTIONS never
// mutate state in this app (see the M2 fix that removed the one exception that used
// to exist) so they're exempt, matching standard CSRF-defense scope.
var csrfProtectedMethods = map[string]bool{http.MethodPost: true, http.MethodPut: true, http.MethodPatch: true, http.MethodDelete: true}
// CSRFProtect rejects state-changing requests whose csrf_token doesn't match what
// csrfTokenFor computes for the request's own session cookie. Every authenticated
// HTML form gets the token auto-injected as a hidden field, and every same-origin
// fetch() call gets it auto-attached as an X-CSRF-Token header — both via the shared
// csrf_script.html partial parsed into every page (see render.go/loadTemplates) —
// so no individual handler or template needed to change for this to apply
// uniformly across the whole app, admin and webmail alike.
func (a *App) CSRFProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !csrfProtectedMethods[r.Method] {
next.ServeHTTP(w, r)
return
}
expected := a.csrfTokenFor(r)
if expected == "" {
// No session cookie at all — an unauthenticated route (e.g. the login POST
// itself). Nothing to protect yet.
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-CSRF-Token")
if got == "" {
// Only fall back to parsing the body if the header wasn't already present —
// keeps the common fetch()-with-header path from ever triggering an implicit
// multipart parse here (the handler still parses it normally afterward).
got = r.FormValue("csrf_token")
}
if got == "" || !hmac.Equal([]byte(got), []byte(expected)) {
http.Error(w, "Forbidden: missing or invalid CSRF token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+155
View File
@@ -0,0 +1,155 @@
package webui
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
)
var csrfTokenInPage = regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`)
// TestCSRFProtectionAppliesAcrossAdminAndWebmail is a live-HTTP test (real
// httptest.NewServer wrapped exactly like main.go composes it —
// SecurityHeaders(app.CSRFProtect(mux)) — not just httptest.NewRecorder against the
// bare mux) confirming: a forged/missing CSRF token on a state-changing POST is
// rejected for BOTH an admin route and a webmail route, a real page-driven
// submission (token scraped from the actual rendered page, exactly as the injected
// csrf_script.html partial would hand it to a real form) succeeds, and every
// response carries the new security headers.
func TestCSRFProtectionAppliesAcrossAdminAndWebmail(t *testing.T) {
app := newTestApp(t)
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
defer srv.Close()
// Security headers present on a plain unauthenticated GET too.
headResp, err := http.Get(srv.URL + Prefix + "/login")
if err != nil {
t.Fatal(err)
}
headResp.Body.Close()
if headResp.Header.Get("X-Frame-Options") != "SAMEORIGIN" {
t.Fatalf("expected X-Frame-Options on every response, got headers: %v", headResp.Header)
}
if headResp.Header.Get("Content-Security-Policy") == "" {
t.Fatal("expected a Content-Security-Policy header")
}
adminCookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "csrf-mailbox@example.com", domains[0].ID, "csrf-password-1!")
mailboxCookie := webmailLoginSession(t, app, mailboxID)
jarClient := func(cookie *http.Cookie) *http.Client {
jar, _ := cookiejar.New(nil)
u, _ := url.Parse(srv.URL)
jar.SetCookies(u, []*http.Cookie{cookie})
return &http.Client{Jar: jar}
}
// Regression check: the compose popup (webmail_compose_widget.html) loads
// /webmail/mail/compose in a same-origin <iframe> — X-Frame-Options: DENY or
// frame-ancestors 'none' would silently break that popup (this exact regression
// shipped once already), so explicitly confirm the compose route itself allows
// same-origin framing.
composeResp, err := jarClient(mailboxCookie).Get(srv.URL + MailboxPrefix + "/mail/compose")
if err != nil {
t.Fatal(err)
}
composeResp.Body.Close()
if fo := composeResp.Header.Get("X-Frame-Options"); fo == "DENY" {
t.Fatalf("compose route sets X-Frame-Options: DENY — this breaks the compose popup's own same-origin iframe")
}
if csp := composeResp.Header.Get("Content-Security-Policy"); strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("compose route's CSP sets frame-ancestors 'none' — this breaks the compose popup's own same-origin iframe, got: %s", csp)
}
scrapeCSRFToken := func(client *http.Client, path string) string {
t.Helper()
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
m := csrfTokenInPage.FindSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token found in rendered page %s: %s", path, body)
}
return string(m[1])
}
// --- Admin route: use the always-present, state-changing "logout" POST (changing
// an account setting would need extra setup like enabling TOTP first).
adminClient := jarClient(adminCookie)
adminToken := scrapeCSRFToken(adminClient, Prefix+"/")
forgedResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedResp.Body.Close()
if forgedResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a forged CSRF token, got %d", forgedResp.StatusCode)
}
missingResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{})
if err != nil {
t.Fatal(err)
}
missingResp.Body.Close()
if missingResp.StatusCode != http.StatusForbidden {
t.Fatalf("admin route: expected 403 for a missing CSRF token, got %d", missingResp.StatusCode)
}
realResp, err := adminClient.PostForm(srv.URL+Prefix+"/logout", url.Values{"csrf_token": {adminToken}})
if err != nil {
t.Fatal(err)
}
realResp.Body.Close()
if realResp.StatusCode != http.StatusOK && realResp.StatusCode != http.StatusFound {
t.Fatalf("admin route: expected the real-token logout to succeed, got %d", realResp.StatusCode)
}
// --- Webmail route: use the webmail logout POST the same way.
mailboxClient := jarClient(mailboxCookie)
mailboxToken := scrapeCSRFToken(mailboxClient, MailboxPrefix+"/mail/INBOX")
forgedMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {"forged-not-real"}})
if err != nil {
t.Fatal(err)
}
forgedMail.Body.Close()
if forgedMail.StatusCode != http.StatusForbidden {
t.Fatalf("webmail route: expected 403 for a forged CSRF token, got %d", forgedMail.StatusCode)
}
realMail, err := mailboxClient.PostForm(srv.URL+MailboxPrefix+"/logout", url.Values{"csrf_token": {mailboxToken}})
if err != nil {
t.Fatal(err)
}
realMail.Body.Close()
if realMail.StatusCode != http.StatusOK && realMail.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the real-token logout to succeed, got %d", realMail.StatusCode)
}
// Also confirm the header-based path works (what the fetch() wrapper uses) —
// re-login first since the account above just logged itself out.
mailboxCookie2 := webmailLoginSession(t, app, mailboxID)
mailboxClient2 := jarClient(mailboxCookie2)
mailboxToken2 := scrapeCSRFToken(mailboxClient2, MailboxPrefix+"/mail/INBOX")
req, _ := http.NewRequest(http.MethodPost, srv.URL+MailboxPrefix+"/logout", strings.NewReader(""))
req.Header.Set("X-CSRF-Token", mailboxToken2)
headerResp, err := mailboxClient2.Do(req)
if err != nil {
t.Fatal(err)
}
headerResp.Body.Close()
if headerResp.StatusCode != http.StatusOK && headerResp.StatusCode != http.StatusFound {
t.Fatalf("webmail route: expected the header-tokened logout to succeed, got %d", headerResp.StatusCode)
}
}
+27 -2
View File
@@ -3,6 +3,7 @@ package webui
import (
"net/http"
"strings"
"time"
"mailgoserver/internal/db"
)
@@ -62,12 +63,36 @@ func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
}
}
a.render(w, r, "dashboard.html", M{
data := M{
"active": "dashboard",
"mailboxes_near_quota": mailboxesNearQuota,
"recent_emails": recentEmails,
"recent_auths": recentAuths,
})
}
// Attack-count tiles: blacklist entries aren't attributable to a single domain
// (see blacklist.go's doc comment), so this is global-admin-only, matching the
// Blacklist page and sidebar section's own gating.
if isGlobal {
now := time.Now()
since24h := now.Add(-24 * time.Hour)
since7d := now.Add(-7 * 24 * time.Hour)
data["failed_auth_24h"], _ = a.DB.CountFailedAuthSince(since24h)
data["failed_auth_7d"], _ = a.DB.CountFailedAuthSince(since7d)
data["blacklist_events_24h"], _ = a.DB.CountBlacklistEventsSince(since24h)
data["blacklist_events_7d"], _ = a.DB.CountBlacklistEventsSince(since7d)
var activeBlacklistCount int
if entries, err := a.DB.ListBlacklist(); err == nil {
for _, e := range entries {
if e.ExpiresAt.After(now) {
activeBlacklistCount++
}
}
}
data["active_blacklist_count"] = activeBlacklistCount
}
a.render(w, r, "dashboard.html", data)
}
// authLogDomain best-effort extracts a domain name from an AuthLog identifier, whose
+15 -4
View File
@@ -43,6 +43,9 @@ func (a *App) loginForm(w http.ResponseWriter, r *http.Request) {
// loginSubmit checks username+password, then either starts a fully-verified session
// (no second factor enabled) or a pending-MFA state that requires /login/mfa next.
func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := r.FormValue("next")
@@ -51,6 +54,11 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "login.html", M{"error": msg, "username": username, "next": next})
}
if a.accountLocked("admin_login", username) {
fail("Too many failed attempts for this account. Try again later.")
return
}
user, err := a.DB.GetAdminUserByUsername(username)
if err != nil {
a.Logger.Error("login lookup: %v", err)
@@ -58,7 +66,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil || !db.CheckPassword(password, user.PasswordHash) {
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), false, "Incorrect username or password")
_ = a.DB.LogAuthAttempt("admin_login", username, a.requestIP(r), false, "Incorrect username or password")
fail("Incorrect username or password.")
return
}
@@ -76,7 +84,7 @@ func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
fail("Something went wrong. Try again.")
return
}
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), true, "Login successful")
_ = a.DB.LogAuthAttempt("admin_login", username, a.requestIP(r), true, "Login successful")
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
return
@@ -114,6 +122,9 @@ func (a *App) mfaForm(w http.ResponseWriter, r *http.Request) {
// mfaSubmit verifies the TOTP code for the pending login and, on success, promotes
// the pending state into a real, fully-verified session.
func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
userID := pendingMFAUserID(r)
next := r.FormValue("next")
if userID == 0 {
@@ -129,7 +140,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.FormValue("code"))
if !user.TOTPEnabled || !totp.Validate(code, user.TOTPSecret) {
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Invalid MFA code")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), false, "Invalid MFA code")
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": next, "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code.",
@@ -143,7 +154,7 @@ func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (authenticator app)")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), true, "Login successful (authenticator app)")
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
+35 -4
View File
@@ -42,6 +42,11 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
page = 1
}
offset := (page - 1) * perPage
// Read once here (not just inside the "auth" case) so every branch's M literal
// can set it unconditionally — logs.html's pagination links reference it regardless
// of filter_type, and a map[string]any with the key entirely absent renders
// inconsistently across template functions versus one that's always present as "".
authCategory := r.URL.Query().Get("auth_category")
switch filterType {
case "emails":
@@ -58,7 +63,8 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
recipientMap, attachMap := a.buildLogMaps(emails)
a.render(w, r, "logs.html", M{
"active": "logs", "logs": emails, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"recipient_logs_map": recipientMap, "attachments_map": attachMap,
})
case "auth":
@@ -68,13 +74,14 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
}
var auths []db.AuthLog
for _, au := range fetched {
if authAllowed(au) {
if authAllowed(au) && authCategoryMatches(au.AuthType, authCategory) {
auths = append(auths, au)
}
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": auths, "filter_type": filterType, "page": page,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(fetched) == perPage, "has_prev": page > 1,
})
default:
half := perPage / 2
@@ -109,11 +116,35 @@ func (a *App) logs(w http.ResponseWriter, r *http.Request) {
}
a.render(w, r, "logs.html", M{
"active": "logs", "logs": logs, "filter_type": filterType, "page": page,
"has_next": len(logs) > perPage, "has_prev": page > 1,
"auth_category": authCategory,
"has_next": len(logs) > perPage, "has_prev": page > 1,
})
}
}
// authCategoryMatches buckets esrv_auth_logs.auth_type values into "admin" (dashboard
// login/MFA), "webmail" (mailbox portal login/MFA), or "mailserver" (SMTP/IMAP — the
// same set abuseguard counts, see crud_ip_blacklist.go's smtpImapAuthTypesSQL). An
// empty category matches everything (no filter applied).
func authCategoryMatches(authType, category string) bool {
switch category {
case "", "all":
return true
case "admin":
return authType == "admin_login" || authType == "admin_mfa"
case "webmail":
return authType == "webmail_login" || authType == "mailbox_mfa"
case "mailserver":
switch authType {
case "sender", "mailbox", "sender_validation", "mailbox_validation", "ip", "imap_login":
return true
}
return false
default:
return true
}
}
func (a *App) buildLogMaps(emails []db.EmailLog) (map[int64][]db.EmailRecipientLog, map[int64][]db.EmailAttachment) {
recipientMap := map[int64][]db.EmailRecipientLog{}
attachMap := map[int64][]db.EmailAttachment{}
+62
View File
@@ -0,0 +1,62 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAuthCategoryMatches(t *testing.T) {
cases := []struct {
authType, category string
want bool
}{
{"admin_login", "admin", true},
{"admin_mfa", "admin", true},
{"webmail_login", "admin", false},
{"webmail_login", "webmail", true},
{"mailbox_mfa", "webmail", true},
{"sender", "webmail", false},
{"sender", "mailserver", true},
{"mailbox", "mailserver", true},
{"sender_validation", "mailserver", true},
{"mailbox_validation", "mailserver", true},
{"ip", "mailserver", true},
{"imap_login", "mailserver", true},
{"admin_login", "mailserver", false},
{"anything", "", true},
{"anything", "all", true},
}
for _, c := range cases {
if got := authCategoryMatches(c.authType, c.category); got != c.want {
t.Errorf("authCategoryMatches(%q, %q) = %v, want %v", c.authType, c.category, got, c.want)
}
}
}
// TestLogsAuthCategoryFilterEndToEnd confirms the ?auth_category= query param actually
// filters the rendered auth-log rows, not just the pure bucketing function above.
func TestLogsAuthCategoryFilterEndToEnd(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
app.DB.LogAuthAttempt("admin_login", "someadmin", "203.0.113.1", false, "bad password")
app.DB.LogAuthAttempt("sender", "someone@example.com", "203.0.113.2", false, "bad password")
req := httptest.NewRequest(http.MethodGet, Prefix+"/logs?type=auth&auth_category=mailserver", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "someone@example.com") {
t.Error("mailserver category should include the sender auth failure")
}
if strings.Contains(body, "someadmin") {
t.Error("mailserver category should exclude the admin_login failure")
}
}
+50 -6
View File
@@ -4,11 +4,51 @@ import (
"net/http"
"strconv"
"strings"
"mailgoserver/internal/db"
)
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}
var validActions = map[string]bool{"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true}
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
// arrays (one value per condition row, same index across all three) — shared by the
// admin and self-service "add rule" handlers, which both submit the same form shape.
// r.ParseForm() must already have been called.
func parseRuleConditions(r *http.Request) ([]db.RuleCondition, bool) {
fields := r.PostForm["condition_field"]
ops := r.PostForm["condition_op"]
values := r.PostForm["condition_value"]
if len(fields) == 0 || len(fields) != len(ops) || len(fields) != len(values) {
return nil, false
}
conditions := make([]db.RuleCondition, 0, len(fields))
for i, field := range fields {
op := ops[i]
value := strings.TrimSpace(values[i])
if !validConditionFields[field] || !validConditionOps[op] || value == "" {
return nil, false
}
conditions = append(conditions, db.RuleCondition{Field: field, Op: op, Value: value})
}
return conditions, true
}
// summarizeConditions renders a rule's condition list as a human-readable string for
// display, e.g. `to contains "sales" AND subject contains "invoice"`.
func summarizeConditions(r db.MailboxFilterRule) string {
conditions, matchType := r.Conditions()
joiner := " AND "
if matchType == "any" {
joiner = " OR "
}
parts := make([]string, len(conditions))
for i, c := range conditions {
parts[i] = c.Field + " " + strings.ReplaceAll(c.Op, "_", " ") + ` "` + c.Value + `"`
}
return strings.Join(parts, joiner)
}
func (a *App) rulesList(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
@@ -29,14 +69,18 @@ func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form submission")
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/rules", http.StatusFound)
return
}
priority, _ := strconv.Atoi(r.FormValue("priority"))
field := r.FormValue("condition_field")
op := r.FormValue("condition_op")
value := strings.TrimSpace(r.FormValue("condition_value"))
matchType := r.FormValue("match_type")
action := r.FormValue("action")
actionValue := strings.TrimSpace(r.FormValue("action_value"))
if !validConditionFields[field] || !validConditionOps[op] || value == "" || !validActions[action] {
conditions, ok := parseRuleConditions(r)
if !ok || !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
@@ -46,7 +90,7 @@ func (a *App) addRule(w http.ResponseWriter, r *http.Request) {
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 {
if _, err := a.DB.CreateRuleMulti(mailbox.ID, priority, conditions, matchType, action, actionValue); err != nil {
setFlash(w, "error", "Error creating rule")
} else {
setFlash(w, "success", "Rule added")
+55
View File
@@ -0,0 +1,55 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestAdminRulesAddMultiConditionAndRenders confirms the admin-side rule builder
// (mirroring the self-service one) accepts a multi-condition submission and that the
// rules list page actually renders it (ruleSummary executes correctly at runtime,
// not just parses at template-load time).
func TestAdminRulesAddMultiConditionAndRenders(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
domains, _ := app.DB.ListDomains()
mbox := createMailboxFor(t, app, "adminruler@example.com", domains[0].ID)
form := url.Values{
"priority": {"0"},
"match_type": {"all"},
"condition_field": {"to", "subject"},
"condition_op": {"contains", "contains"},
"condition_value": {"sales", "invoice"},
"action": {"mark_as_spam"},
"action_value": {""},
}
addReq := httptest.NewRequest(http.MethodPost, Prefix+"/mailboxes/"+strconv.FormatInt(mbox.ID, 10)+"/rules/add", strings.NewReader(form.Encode()))
addReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addReq.AddCookie(cookie)
addRec := httptest.NewRecorder()
mux.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusFound {
t.Fatalf("add rule: status=%d body=%s", addRec.Code, addRec.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, Prefix+"/mailboxes/"+strconv.FormatInt(mbox.ID, 10)+"/rules", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("rules list: status=%d body=%s", listRec.Code, listRec.Body.String())
}
body := listRec.Body.String()
if !strings.Contains(body, "to contains &#34;sales&#34;") || !strings.Contains(body, "AND") {
t.Fatalf("expected the rendered condition summary to show both AND'd conditions, got: %s", body)
}
if !strings.Contains(body, "Mark as Spam") {
t.Fatal("expected the mark_as_spam action to render")
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ func (a *App) resetMailboxMFA(w http.ResponseWriter, r *http.Request) {
if err := a.DB.ResetMailboxMFA(mailbox.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
_ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username)
setFlash(w, "success", "MFA reset for "+mailbox.Email)
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
+13 -6
View File
@@ -134,9 +134,9 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
t.Fatal("expected a session cookie despite no MFA configured")
}
// The dashboard, password change, and app-password creation are ALL redirected
// to the isolated setup page — nothing else is reachable in the browser.
blockedGets := []string{MailboxPrefix + "/"}
// The mailbox, account page, password change, and app-password creation are ALL
// redirected to the isolated setup page — nothing else is reachable in the browser.
blockedGets := []string{MailboxPrefix + "/", MailboxPrefix + "/account"}
for _, path := range blockedGets {
req = httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
@@ -193,8 +193,8 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
t.Error("expected no other portal sections on the isolated setup page")
}
// Once TOTP is configured, everything works normally again — dashboard, password
// change, and app passwords.
// Once TOTP is configured, everything works normally again — mailbox, account
// page, password change, and app passwords.
if err := app.DB.SetMailboxTOTPSecret(mboxID, "JBSWY3DPEHPK3PXP", true); err != nil {
t.Fatal(err)
}
@@ -202,8 +202,15 @@ func TestMailboxMFAEnforcementLetsLoginThroughButIsolatesEverythingElse(t *testi
req.AddCookie(cookie)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != MailboxPrefix+"/mail/INBOX" {
t.Fatalf("mailbox root: expected reachable (redirect to inbox) after enabling MFA, got %d Location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, MailboxPrefix+"/account", nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard: expected reachable after enabling MFA, got %d", rec.Code)
t.Fatalf("account page: expected reachable after enabling MFA, got %d", rec.Code)
}
req = httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/password", strings.NewReader(pwForm.Encode()))
+13 -2
View File
@@ -1,12 +1,15 @@
package webui
import "net/http"
import (
"net/http"
"time"
)
// 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
DomainCount, SenderCount, MailboxCount, IPCount, DKIMCount, BlacklistCount int
}
func (a *App) computeNavCounts(r *http.Request) navCounts {
@@ -17,6 +20,14 @@ func (a *App) computeNavCounts(r *http.Request) navCounts {
c.DomainCount, _ = a.DB.CountActiveDomains()
c.SenderCount, _ = a.DB.CountActiveSenders()
c.DKIMCount, _ = a.DB.CountActiveDKIMKeys()
if entries, err := a.DB.ListBlacklist(); err == nil {
now := time.Now()
for _, e := range entries {
if e.ExpiresAt.After(now) {
c.BlacklistCount++
}
}
}
} else {
domains, _ := a.DB.ListDomains()
for _, d := range domains {
+74
View File
@@ -0,0 +1,74 @@
package webui
import (
"net/http"
"sync"
"time"
)
// ipRateLimiter is a small in-memory fixed-window counter — same map+mutex shape as
// pgpKeyCache elsewhere in this package. Bounds how many login POSTs a single source
// IP can make per window, independent of the per-account lockout in login.go/
// webmail_login.go (that one tracks failures against one identifier from any IP;
// this one bounds request volume from one IP regardless of which account(s) it's
// trying — the two layers catch different attack shapes: a botnet spreading guesses
// across many accounts, versus one machine hammering a single account).
type ipRateLimiter struct {
mu sync.Mutex
limit int
window time.Duration
counts map[string]*ipWindow
}
type ipWindow struct {
count int
windowEnds time.Time
}
func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter {
return &ipRateLimiter{limit: limit, window: window, counts: map[string]*ipWindow{}}
}
// allow reports whether ip may make another request right now, incrementing its
// count as a side effect. Expired windows reset lazily on next access rather than
// via a background sweep — fine at this app's scale (a handful of login attempts
// per real user), and avoids a goroutine that outlives the App's own lifecycle.
func (l *ipRateLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
w, ok := l.counts[ip]
if !ok || now.After(w.windowEnds) {
w = &ipWindow{count: 0, windowEnds: now.Add(l.window)}
l.counts[ip] = w
}
w.count++
return w.count <= l.limit
}
// rateLimitLogin replies 429 and returns false if the request's source IP has
// exceeded the per-IP login rate limit — callers should return immediately without
// touching the DB or checking a password when this returns false.
func (a *App) rateLimitLogin(w http.ResponseWriter, r *http.Request) bool {
if a.loginLimiter.allow(a.requestIP(r)) {
return true
}
http.Error(w, "Too many login attempts — try again in a minute.", http.StatusTooManyRequests)
return false
}
// accountLocked reports whether authType/identifier has accumulated enough recent
// failures (from any IP — see ratelimit.go's doc comment for why that's the point)
// to refuse another attempt right now, per the [Auth] login_attempt_limit/window_minutes
// config. Fails open (returns false) on a DB error rather than locking everyone out
// over a transient issue.
func (a *App) accountLocked(authType, identifier string) bool {
limit := a.Cfg.Section("Auth").Key("login_attempt_limit").MustInt(8)
windowMinutes := a.Cfg.Section("Auth").Key("login_attempt_window_minutes").MustInt(15)
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
n, err := a.DB.CountRecentFailedAttempts(authType, identifier, since)
if err != nil {
return false
}
return n >= limit
}
+95
View File
@@ -0,0 +1,95 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestWebmailLoginAccountLockout confirms repeated wrong-password attempts against
// one account eventually get refused with a generic lockout message rather than
// checking the password at all, and that the lockout doesn't touch a different
// account from the same IP (the per-IP throttle, not this per-account layer, would
// apply there).
func TestWebmailLoginAccountLockout(t *testing.T) {
app := newTestApp(t)
app.Cfg.Section("Auth").Key("login_attempt_limit").SetValue("3")
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "lockout-victim@example.com", domains[0].ID, "the-real-password-1!")
otherID := createTestMailboxWithPassword(t, app, "lockout-other@example.com", domains[0].ID, "another-password-1!")
attempt := func(email, password string) *httptest.ResponseRecorder {
form := url.Values{"email": {email}, "password": {password}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.9:12345"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
rec := attempt("lockout-victim@example.com", "wrong password")
if !strings.Contains(rec.Body.String(), "Incorrect email or password") {
t.Fatalf("attempt %d: expected a normal wrong-password error, got: %s", i, rec.Body.String())
}
}
locked := attempt("lockout-victim@example.com", "the-real-password-1!") // even the CORRECT password now
if !strings.Contains(locked.Body.String(), "Too many failed attempts") {
t.Fatalf("expected the account locked out after repeated failures, got: %s", locked.Body.String())
}
if locked.Result().Cookies() != nil {
for _, c := range locked.Result().Cookies() {
if c.Name == mailboxSessionCookieName && c.Value != "" {
t.Fatal("expected no session granted while locked out, even with the correct password")
}
}
}
// A different account from the same IP is unaffected by the per-account lockout.
rec := attempt("lockout-other@example.com", "another-password-1!")
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == mailboxSessionCookieName {
found = true
}
}
if !found {
t.Fatal("expected a different account from the same IP to log in normally")
}
_ = victimID
_ = otherID
}
// TestLoginRateLimitPerIP confirms the per-IP throttle kicks in independent of which
// account is being tried, once enough requests arrive from one source IP.
func TestLoginRateLimitPerIP(t *testing.T) {
app := newTestApp(t)
app.loginLimiter = newIPRateLimiter(3, 1<<62) // tiny limit, effectively-infinite window for a deterministic test
mux := app.Mux()
domains, _ := app.DB.ListDomains()
createTestMailboxWithPassword(t, app, "ratelimit@example.com", domains[0].ID, "correct-password-1!")
attempt := func() *httptest.ResponseRecorder {
form := url.Values{"email": {"ratelimit@example.com"}, "password": {"correct-password-1!"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "203.0.113.10:12345"
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if rec := attempt(); rec.Code == http.StatusTooManyRequests {
t.Fatalf("attempt %d: unexpectedly rate-limited early", i)
}
}
if rec := attempt(); rec.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 past the per-IP limit, got %d", rec.Code)
}
}
+60 -13
View File
@@ -43,16 +43,17 @@ func (a *App) funcMap() template.FuncMap {
// 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,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
"isPast": func(t *time.Time) bool { return t != nil && t.Before(time.Now()) },
"ruleSummary": summarizeConditions,
"title": strings.Title,
"upper": strings.ToUpper,
"lower": strings.ToLower,
"safe": func(s string) template.HTML { return template.HTML(s) },
"filesize": humanFileSize,
"dotToDash": func(s string) string { return strings.ReplaceAll(s, ".", "-") },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"eq2": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
// dget looks up an optional map key, returning "" if absent — mirrors Jinja's
// `x if x is defined else ''` pattern used for context vars only some pages set
// (e.g. sidebar badge counts, which only dashboard passes).
@@ -62,7 +63,8 @@ func (a *App) funcMap() template.FuncMap {
}
return ""
},
"list": func(items ...string) []string { return items },
"list": func(items ...string) []string { return items },
"isStandardFolder": isStandardFolder,
// emailOverallStatus mirrors the delivered/failed selectattr computation
// dashboard.html and logs.html both do in the Python templates.
"emailOverallStatus": func(recipients []db.EmailRecipientLog) string {
@@ -130,6 +132,7 @@ var pages = []string{
"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",
"blacklist.html",
"dkim.html", "edit_dkim.html",
"settings.html", "letsencrypt.html", "logs.html", "view_message_content.html", "error.html",
"account.html", "first_login.html",
@@ -144,6 +147,39 @@ var pages = []string{
var standalonePages = []string{
"login.html", "login_mfa.html", "mfa_setup_required.html", "totp_setup.html",
"webmail_login.html", "webmail_login_mfa.html", "webmail_account.html", "webmail_totp_setup.html", "webmail_mfa_setup_required.html",
"webmail_folder.html", "webmail_message.html", "webmail_compose.html", "webmail_rules.html", "webmail_certs.html",
}
// pagesWithComposeWidget are the standalone pages that show a Compose/Reply/Forward
// entry point and so need webmail_compose_widget.html's floating-popup markup+JS
// parsed alongside them (see webmail_compose_widget.html's {{define "compose_widget"}}).
// webmail_compose.html itself is excluded — it's what opens inside the popup, not
// something that opens a popup of its own.
var pagesWithComposeWidget = []string{
"webmail_folder.html", "webmail_message.html", "webmail_rules.html", "webmail_certs.html", "webmail_account.html",
}
func hasComposeWidget(page string) bool {
for _, p := range pagesWithComposeWidget {
if p == page {
return true
}
}
return false
}
// pagesWithShortcuts are the two pages keyboard shortcuts make sense on — the
// message list (j/k/Enter/o) and a single open message (r/a/f/#). See
// webmail_shortcuts.html's {{define "webmail_shortcuts"}}.
var pagesWithShortcuts = []string{"webmail_folder.html", "webmail_message.html"}
func hasShortcuts(page string) bool {
for _, p := range pagesWithShortcuts {
if p == page {
return true
}
}
return false
}
// loadTemplates parses from the embedded assets FS (see embed.go), not the
@@ -153,7 +189,7 @@ func (a *App) loadTemplates() error {
a.templates = map[string]*template.Template{}
for _, page := range pages {
t := template.New("base.html").Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/"+page)
t, err := t.ParseFS(assets, "templates/base.html", "templates/sidebar_email.html", "templates/csrf_script.html", "templates/"+page)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
@@ -161,7 +197,14 @@ func (a *App) loadTemplates() error {
}
for _, page := range standalonePages {
t := template.New(page).Funcs(a.funcMap())
t, err := t.ParseFS(assets, "templates/"+page)
files := []string{"templates/" + page, "templates/csrf_script.html"}
if hasComposeWidget(page) {
files = append(files, "templates/webmail_compose_widget.html")
}
if hasShortcuts(page) {
files = append(files, "templates/webmail_shortcuts.html")
}
t, err := t.ParseFS(assets, files...)
if err != nil {
return fmt.Errorf("parse %s: %w", page, err)
}
@@ -190,6 +233,9 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
if data == nil {
data = M{}
}
// Set unconditionally for every page — pages with no session cookie yet (login)
// just get "", which csrf_script.html's injected script treats as a no-op.
data["csrf_token"] = a.csrfTokenFor(r)
if isStandalonePage(page) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, page, data); err != nil {
@@ -214,6 +260,7 @@ func (a *App) render(w http.ResponseWriter, r *http.Request, page string, data M
data["mailbox_count"] = counts.MailboxCount
data["ip_count"] = counts.IPCount
data["dkim_count"] = counts.DKIMCount
data["blacklist_count"] = counts.BlacklistCount
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)
+39
View File
@@ -0,0 +1,39 @@
package webui
import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
)
const appSecretSize = 32
// LoadOrCreateAppSecret reads the app's CSRF-signing secret from path, generating a
// fresh random one on first run if the file doesn't exist yet — mirrors
// mailstore.LoadOrCreateMasterKey's identical generate-if-missing pattern for the
// mailstore encryption key. Unlike that key, losing this one has no data-loss
// consequence: every outstanding CSRF token just stops validating, so users get
// logged-out-feeling form-submit errors until they reload a page for a fresh one.
func LoadOrCreateAppSecret(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil {
if len(b) != appSecretSize {
return nil, fmt.Errorf("app secret at %s is %d bytes, want %d", path, len(b), appSecretSize)
}
return b, nil
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
secret := make([]byte, appSecretSize)
if _, err := rand.Read(secret); err != nil {
return nil, err
}
if err := os.WriteFile(path, secret, 0o600); err != nil {
return nil, err
}
return secret, nil
}
+47
View File
@@ -0,0 +1,47 @@
package webui
import "net/http"
// contentSecurityPolicy — Bootstrap/Bootstrap Icons/Quill are all vendored locally
// under static/vendor/ (no CDN dependency left anywhere), so this only needs 'self'.
//
// frame-ancestors 'self' (not 'none'): the webmail compose popup
// (webmail_compose_widget.html) legitimately loads /webmail/mail/compose in an
// <iframe> on the SAME origin — 'none' blocks ALL framing including that, which
// broke the popup entirely (it's framing, just not the third-party clickjacking
// kind this header exists to stop). 'self' still blocks any other site from
// framing this app.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: https:; " +
"frame-ancestors 'self'"
// SecurityHeaders sets standard hardening headers on every response — wrapped once
// around the whole app's handler in main.go so admin and webmail routes (and login,
// static assets, /health) all get it uniformly, rather than duplicating the wrap at
// multiple mux-registration points.
//
// 'unsafe-inline' is required for both script-src and style-src: every template in
// this codebase uses inline <script>/<style> blocks (no nonce or hash pipeline
// exists), so a strict CSP would break every page. This still meaningfully narrows
// the attack surface versus no CSP at all — it blocks loading script/style/fonts
// from any origin other than this server and jsdelivr, which is what actually
// matters against a stored-XSS-via-inbound-HTML-mail scenario (the message view
// sanitizes HTML mail with bluemonday before rendering, but CSP is defense in depth
// for exactly that class of bug).
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
// SAMEORIGIN, not DENY: the compose popup frames /webmail/mail/compose in an
// iframe on this same origin — DENY blocked that too (see
// contentSecurityPolicy's frame-ancestors comment for the matching CSP fix).
h.Set("X-Frame-Options", "SAMEORIGIN")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
h.Set("Content-Security-Policy", contentSecurityPolicy)
next.ServeHTTP(w, r)
})
}
+35
View File
@@ -0,0 +1,35 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSecurityHeadersSetOnEveryResponse confirms the hardening headers are present
// regardless of which underlying handler produced the response — admin, webmail, or
// anything else, since main.go wraps the whole app's handler once with this rather
// than per-route.
func TestSecurityHeadersSetOnEveryResponse(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := SecurityHeaders(inner)
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cases := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "same-origin",
"Content-Security-Policy": contentSecurityPolicy,
}
for header, want := range cases {
if got := rec.Header().Get(header); got != want {
t.Errorf("header %s = %q, want %q", header, got, want)
}
}
if rec.Header().Get("Permissions-Policy") == "" {
t.Error("expected a Permissions-Policy header")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -13,8 +13,8 @@
}
</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">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root { --sidebar-width: 280px; }
@@ -65,6 +65,7 @@
{{block "extra_css" .}}{{end}}
</head>
<body>
{{template "csrf_script" .}}
<div class="main-container">
{{template "sidebar_email.html" .}}
@@ -128,7 +129,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
function updateTime() {
+116
View File
@@ -0,0 +1,116 @@
{{define "title"}}Blacklist - Email Server{{end}}
{{define "content"}}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-shield-x me-2"></i>SMTP/IMAP Abuse Blacklist</h2>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-list me-2"></i>Blacklisted IP Addresses</h5></div>
<div class="card-body">
{{if .entries}}
<div class="table-responsive">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Reason</th><th>Offense #</th><th>Type</th><th>Blacklisted</th><th>Expires</th><th>Actions</th></tr></thead>
<tbody>
{{range .entries}}
<tr>
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
<td><small class="text-muted">{{.Reason}}</small></td>
<td>{{.OffenseCount}}</td>
<td>{{if .Manual}}<span class="badge bg-secondary">Manual</span>{{else}}<span class="badge bg-warning text-dark">Auto</span>{{end}}</td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .BlacklistedAt}}</small></td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .ExpiresAt}}</small></td>
<td>
<div class="btn-group" role="group">
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/whitelist" class="d-inline">
<button type="submit" class="btn btn-outline-success btn-sm" title="Whitelist this IP" data-confirm="Remove {{.IPAddress}} from the blacklist and exempt it from abuse detection?"><i class="bi bi-shield-check"></i></button>
</form>
<form method="post" action="/pymta-manager/blacklist/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove entry" data-confirm="Remove the blacklist entry for {{.IPAddress}}?"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-4">
<i class="bi bi-shield-check text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">No IPs currently blacklisted</h5>
</div>
{{end}}
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Manually Blacklist an IP</h6></div>
<div class="card-body">
<form method="post" action="/pymta-manager/blacklist/add" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">IP Address</label>
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
</div>
<div class="col-md-4">
<label class="form-label small">Reason</label>
<input type="text" name="reason" class="form-control" placeholder="Optional">
</div>
<div class="col-md-2">
<label class="form-label small">Hours</label>
<input type="number" name="hours" class="form-control" value="12" min="1" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-danger w-100"><i class="bi bi-shield-x me-1"></i>Blacklist</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>Abuse-Detection Whitelist</h5></div>
<div class="card-body">
<div class="alert alert-info small">
IPs here are exempt from automatic blacklisting for failed SMTP/IMAP auth. This is separate
from the <a href="/pymta-manager/ips">relay whitelist</a>, which authorizes unauthenticated sending for a domain.
</div>
{{if .whitelist}}
<div class="table-responsive mb-3">
<table class="table table-striped">
<thead><tr><th>IP Address</th><th>Note</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .whitelist}}
<tr>
<td><div class="fw-bold font-monospace">{{.IPAddress}}</div></td>
<td><small class="text-muted">{{.Note}}</small></td>
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .CreatedAt}}</small></td>
<td>
<form method="post" action="/pymta-manager/abuse-whitelist/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove {{.IPAddress}} from the abuse-detection whitelist?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="post" action="/pymta-manager/abuse-whitelist/add" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">IP Address</label>
<input type="text" name="ip_address" class="form-control font-monospace" placeholder="203.0.113.7" required>
</div>
<div class="col-md-5">
<label class="form-label small">Note</label>
<input type="text" name="note" class="form-control" placeholder="Optional">
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-circle me-1"></i>Add</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "csrf_script"}}
<script>
// Auto-applies CSRF protection to every plain <form method=post> and every
// same-origin mutating fetch() call on this page — no per-form or per-fetch-call
// changes needed anywhere else in this codebase. See internal/webui/csrf.go for
// the server-side check this token has to satisfy.
window.__csrfToken = {{.csrf_token}};
(function() {
var token = window.__csrfToken;
if (!token) return; // no session cookie yet (e.g. the login page itself)
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('form').forEach(function(form) {
if ((form.getAttribute('method') || '').toLowerCase() !== 'post') return;
if (form.querySelector('input[name="csrf_token"]')) return;
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = token;
form.appendChild(input);
});
});
var mutating = { POST: true, PUT: true, PATCH: true, DELETE: true };
var originalFetch = window.fetch.bind(window);
window.fetch = function(url, options) {
options = options || {};
var method = (options.method || 'GET').toUpperCase();
var isAbsolute = /^https?:\/\//i.test(url);
var isSameOrigin = !isAbsolute || url.indexOf(window.location.origin) === 0;
if (mutating[method] && isSameOrigin) {
options = Object.assign({}, options);
var headers = new Headers(options.headers || {});
headers.set('X-CSRF-Token', token);
options.headers = headers;
}
return originalFetch(url, options);
};
})();
</script>
{{end}}
+37
View File
@@ -181,6 +181,43 @@
</div>
</div>
{{if dget . "is_global_admin"}}
<div class="row">
<div class="col-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-shield-x me-2"></i>Attack Activity</h5>
<a href="/pymta-manager/blacklist" class="btn btn-outline-light btn-sm">View Blacklist</a>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 {{if gt (dget . "active_blacklist_count") 0}}text-danger{{else}}text-secondary{{end}}">{{dget . "active_blacklist_count"}}</div>
<small class="text-muted">Active Blocks</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-warning">{{dget . "failed_auth_24h"}}</div>
<small class="text-muted">Failed Auth (24h)</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-warning">{{dget . "failed_auth_7d"}}</div>
<small class="text-muted">Failed Auth (7d)</small>
</div>
<div class="col-6 col-md-2 mb-3 mb-md-0">
<div class="fs-3 text-danger">{{dget . "blacklist_events_24h"}}</div>
<small class="text-muted">Blacklist Events (24h)</small>
</div>
<div class="col-6 col-md-2">
<div class="fs-3 text-danger">{{dget . "blacklist_events_7d"}}</div>
<small class="text-muted">Blacklist Events (7d)</small>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
<div class="row">
<div class="col-12">
<div class="card">
+3 -2
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in - mailgoserver</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">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/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%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-envelope-fill" style="font-size: 2.5rem;"></i>
+3 -2
View File
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify it's you - mailgoserver</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">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/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%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
+13 -2
View File
@@ -22,6 +22,17 @@
</div>
</div>
{{if eq .filter_type "auth"}}
<div class="d-flex justify-content-end mb-3">
<div class="btn-group btn-group-sm">
<a href="/pymta-manager/logs?type=auth" class="btn {{if or (eq .auth_category "") (eq .auth_category "all")}}btn-secondary{{else}}btn-outline-secondary{{end}}">All</a>
<a href="/pymta-manager/logs?type=auth&auth_category=admin" class="btn {{if eq .auth_category "admin"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Admin</a>
<a href="/pymta-manager/logs?type=auth&auth_category=webmail" class="btn {{if eq .auth_category "webmail"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Webmail</a>
<a href="/pymta-manager/logs?type=auth&auth_category=mailserver" class="btn {{if eq .auth_category "mailserver"}}btn-secondary{{else}}btn-outline-secondary{{end}}">Mail Server</a>
</div>
</div>
{{end}}
<div class="row">
<div class="col-12">
<div class="card">
@@ -134,9 +145,9 @@
{{if or .has_prev .has_next}}
<nav aria-label="Log pagination" class="mt-4">
<ul class="pagination justify-content-center">
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
{{if .has_prev}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&page={{sub .page 1}}"><i class="bi bi-chevron-left"></i> Previous</a></li>{{end}}
<li class="page-item active"><span class="page-link">Page {{.page}}</span></li>
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
{{if .has_next}}<li class="page-item"><a class="page-link" href="/pymta-manager/logs?type={{.filter_type}}&auth_category={{.auth_category}}&page={{add .page 1}}">Next <i class="bi bi-chevron-right"></i></a></li>{{end}}
</ul>
</nav>
{{end}}
+75 -36
View File
@@ -14,44 +14,68 @@
<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;">
<form method="POST" action="/pymta-manager/mailboxes/{{.mailbox.ID}}/rules/add">
<div class="row g-2 align-items-end mb-3">
<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">Match</label>
<select class="form-select" name="match_type">
<option value="all">ALL of the following (AND)</option>
<option value="any">ANY of the following (OR)</option>
</select>
</div>
</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 id="conditions_container"></div>
<button type="button" id="add_condition" class="btn btn-outline-secondary btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
<div class="row g-2 align-items-end">
<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="mark_as_spam">Mark as Spam</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>
</div>
</form>
<template id="condition_row_template">
<div class="row g-2 align-items-end mb-2 condition-row">
<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">
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</template>
</div>
</div>
@@ -66,9 +90,10 @@
{{range .rules}}
<tr>
<td>{{.Priority}}</td>
<td><code>{{.ConditionField}} {{.ConditionOp}} "{{.ConditionValue}}"</code></td>
<td><code>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
{{else}}Mark as read{{end}}
</td>
@@ -100,5 +125,19 @@ 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';
});
function addConditionRow() {
const tpl = document.getElementById('condition_row_template');
const container = document.getElementById('conditions_container');
const clone = document.importNode(tpl.content, true);
clone.querySelector('.remove-condition').addEventListener('click', function() {
if (container.children.length > 1) {
this.closest('.condition-row').remove();
}
});
container.appendChild(clone);
}
document.getElementById('add_condition').addEventListener('click', addConditionRow);
addConditionRow();
</script>
{{end}}
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up two-factor authentication - mailgoserver</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">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<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">
@@ -55,7 +56,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/pymta-manager/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
@@ -86,6 +86,22 @@
</a>
</li>
{{if dget . "is_global_admin"}}
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-shield-lock me-1"></i>
Security
</h6>
</li>
<li class="nav-item mb-1">
<a href="/pymta-manager/blacklist" class="nav-link text-white {{if eq (dget . "active") "blacklist"}}active{{end}}">
<i class="bi bi-shield-x me-2"></i>
Blacklist
<span class="badge bg-secondary ms-auto">{{dget . "blacklist_count"}}</span>
</a>
</li>
{{end}}
<li class="nav-item mb-2">
<h6 class="text-muted text-uppercase small mb-2 mt-3">
<i class="bi bi-gear me-1"></i>
+3 -2
View File
@@ -5,14 +5,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up authenticator app - mailgoserver</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">
<link href="/pymta-manager/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/pymta-manager/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
+15 -6
View File
@@ -5,8 +5,8 @@
<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">
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
@@ -14,12 +14,19 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<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 class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<form method="post" action="/webmail/logout" class="d-inline">
<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>
</div>
</nav>
@@ -170,7 +177,9 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
const TOAST_AUTOHIDE_MS = 5000;
function armToastAutoDismiss(toastEl, bsToast) {
+327
View File
@@ -0,0 +1,327 @@
{{define "webmail_certs.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>Certs - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/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>
{{template "csrf_script" .}}
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<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>
</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">
<h4 class="mb-4"><i class="bi bi-shield-lock me-2"></i>Certs</h4>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Two separate systems live here, each doing one job: <strong>S/MIME certificates sign</strong> outgoing mail (proves it came from you and wasn't altered) — <strong>PGP keys encrypt</strong> it (only the recipient can read it). They're different standards with different key formats; a message can use either, both, or neither. PGP private keys are protected by their own passphrase (never stored anywhere), so you'll be asked for it the first time you use one each session.
</div>
<h5 class="mb-3"><i class="bi bi-pen me-2"></i>S/MIME Certificates <small class="text-muted fs-6">— for signing</small></h5>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-person-badge me-2"></i>Your Certificates</h6></div>
<div class="card-body">
{{if .identities}}
<div class="table-responsive mb-4">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Expires</th><th>Actions</th></tr></thead>
<tbody>
{{range .identities}}
<tr>
<td>{{.NotAfter.Format "2006-01-02"}}</td>
<td class="d-flex gap-2">
<a href="/webmail/smime/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download</a>
<form method="post" action="/webmail/smime/identity/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this S/MIME certificate? Mail signed with it will no longer verify."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted">No S/MIME certificates yet. Generate a free self-signed certificate, or import one you already have (.p12/.pfx).</p>
{{end}}
<hr class="my-4">
<div class="row g-4">
<div class="col-md-6">
<h6>Generate New</h6>
<form method="post" action="/webmail/smime/identity/generate" class="row g-2">
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate Self-Signed Certificate</button>
</div>
</form>
</div>
<div class="col-md-6">
<h6>Import Existing (.p12 / .pfx)</h6>
<form method="post" action="/webmail/smime/identity/import" enctype="multipart/form-data" class="row g-2">
<div class="col-12">
<input type="file" class="form-control form-control-sm" name="p12_file" accept=".p12,.pfx" required>
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="p12_password" placeholder=".p12 export password (if any)">
</div>
<div class="col-12">
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>S/MIME Contact Certificates</h6></div>
<div class="card-body">
<form method="post" action="/webmail/smime/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label">Email</label>
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
</div>
<div class="col-auto">
<label class="form-label">Certificate (.pem/.crt/.cer)</label>
<input type="file" class="form-control form-control-sm" name="cert_file" accept=".pem,.crt,.cer" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
</div>
</form>
{{if .contacts}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .contacts}}
<tr>
<td>{{.Email}}</td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
<form method="post" action="/webmail/smime/contacts/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's certificate?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No contact certificates yet. They're also captured automatically when you open a validly signed email from someone new.</p>
{{end}}
</div>
</div>
<h5 class="mb-3"><i class="bi bi-lock me-2"></i>PGP Keys <small class="text-muted fs-6">— for encryption</small></h5>
<div class="card mb-4">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-key me-2"></i>Your Keys</h6></div>
<div class="card-body">
{{if .pgp_identities}}
<div class="table-responsive mb-4">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Label</th><th>Fingerprint</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{{range .pgp_identities}}
<tr>
<td>{{if .Label}}{{.Label}}{{else}}<span class="text-muted">(no label)</span>{{end}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{if index $.pgp_unlocked .ID}}<span class="badge bg-success">Unlocked this session</span>{{else}}<span class="badge bg-secondary">Locked</span>{{end}}</td>
<td class="d-flex gap-2">
<a href="/webmail/pgp/identity/{{.ID}}/download" class="btn btn-outline-light btn-sm"><i class="bi bi-download me-1"></i>Download Public Key</a>
<form method="post" action="/webmail/pgp/identity/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="Remove this PGP key? Mail encrypted to it will no longer decrypt."><i class="bi bi-trash me-1"></i>Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted">No PGP keys yet. Generate a new keypair, or import one you already have (an ASCII-armored .asc export from e.g. GnuPG).</p>
{{end}}
<hr class="my-4">
<div class="row g-4">
<div class="col-md-6">
<h6>Generate New</h6>
<form method="post" action="/webmail/pgp/identity/generate" class="row g-2">
<div class="col-12">
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label (e.g. &quot;Work&quot;) — optional, helps tell keys apart">
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Choose a passphrase (min 8 chars)" required minlength="8">
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase_confirm" placeholder="Confirm passphrase" required minlength="8">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-magic me-1"></i>Generate PGP Key</button>
</div>
</form>
</div>
<div class="col-md-6">
<h6>Import Existing (.asc)</h6>
<form method="post" action="/webmail/pgp/identity/import" enctype="multipart/form-data" class="row g-2">
<div class="col-12">
<input type="text" class="form-control form-control-sm" name="label" placeholder="Label — optional">
</div>
<div class="col-12">
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
</div>
<div class="col-12">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="The key's passphrase (its own, or a new one if it has none)" required>
</div>
<div class="col-12">
<button type="submit" class="btn btn-secondary btn-sm">Import</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h6 class="mb-0"><i class="bi bi-people me-2"></i>PGP Contact Keys</h6></div>
<div class="card-body">
<form method="post" action="/webmail/pgp/contacts/add" enctype="multipart/form-data" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label">Email</label>
<input type="email" class="form-control form-control-sm" name="email" placeholder="someone@example.com" required>
</div>
<div class="col-auto">
<label class="form-label">Label</label>
<input type="text" class="form-control form-control-sm" name="label" placeholder="optional">
</div>
<div class="col-auto">
<label class="form-label">Public Key (.asc)</label>
<input type="file" class="form-control form-control-sm" name="key_file" accept=".asc,.pem,.gpg" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-circle me-1"></i>Add Contact</button>
</div>
</form>
{{if .pgp_contacts}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead><tr><th>Email</th><th>Label</th><th>Fingerprint</th><th>Added</th><th>Actions</th></tr></thead>
<tbody>
{{range .pgp_contacts}}
<tr>
<td>{{.Email}}</td>
<td>{{.Label}}</td>
<td><code class="small">{{.Fingerprint}}</code></td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
<form method="post" action="/webmail/pgp/contacts/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Remove" data-confirm="Remove this contact's PGP key?"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-muted mb-0">No contact keys yet. Ask a sender for their public key export, or grab it from a keyserver, and add it here before you can encrypt mail to them.</p>
{{end}}
</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>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
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();
}
});
});
});
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,408 @@
{{define "webmail_compose.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>Compose - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
<style>
:root { --cw-bg: #1e1e1e; --cw-panel: #262626; --cw-border: #404040; --cw-text: #e0e0e0; --cw-muted: #9a9a9a; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body { background-color: var(--cw-bg); color: var(--cw-text); font-size: .875rem; }
.compose-shell { display: flex; flex-direction: column; height: 100vh; }
.compose-actionbar { flex: 0 0 auto; display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid var(--cw-border); background-color: var(--cw-panel); flex-wrap: wrap; }
.compose-actionbar .from-email { font-size: .8rem; color: var(--cw-muted); }
.compose-actionbar select.from-select { max-width: 220px; }
.compose-fields { flex: 0 0 auto; padding: 0 .75rem; }
.field-row { display: flex; align-items: center; border-bottom: 1px solid var(--cw-border); padding: .3rem 0; }
.field-row .field-label { width: 42px; flex: 0 0 auto; color: var(--cw-muted); font-size: .8rem; }
.field-row input.bare-input { flex: 1 1 auto; min-width: 0; border: 0; background: transparent; color: var(--cw-text); outline: none; font-size: .85rem; padding: .2rem 0; }
.field-row input.bare-input::placeholder { color: var(--cw-muted); }
.cc-bcc-toggle { flex: 0 0 auto; font-size: .75rem; color: #6ea8fe; cursor: pointer; margin-left: .5rem; white-space: nowrap; }
.cc-bcc-toggle:hover { text-decoration: underline; }
.subject-row input.bare-input { font-size: .95rem; padding: .4rem 0; }
.crypto-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: .4rem 0; font-size: .78rem; color: var(--cw-muted); }
.crypto-row .form-check { margin-bottom: 0; }
.crypto-row .form-check-label { font-size: .8rem; }
.editor-wrap { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; padding: 0 .75rem .5rem; }
.editor-wrap.drag-over { outline: 2px dashed #6ea8fe; outline-offset: -4px; }
#editor { flex: 1 1 auto; min-height: 0; background-color: #fff; color: #000; }
.ql-toolbar.ql-snow { flex: 0 0 auto; background-color: #333; border-color: var(--cw-border); border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
.ql-container.ql-snow { border-color: var(--cw-border); border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
/* Quill's default snow-theme icons are near-black (#444) — invisible on a dark
toolbar. Light stroke/fill + light picker text restores contrast. */
.ql-snow .ql-stroke { stroke: #c8c8c8; }
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
.ql-snow .ql-picker { color: #c8c8c8; }
.ql-snow .ql-picker-label { color: #c8c8c8; border-color: transparent; }
.ql-snow .ql-picker-options { background-color: var(--cw-panel); border-color: var(--cw-border); }
.ql-snow .ql-picker-item { color: #c8c8c8; }
.ql-toolbar.ql-snow button:hover, .ql-toolbar.ql-snow button.ql-active,
.ql-toolbar.ql-snow .ql-picker-label:hover, .ql-toolbar.ql-snow .ql-picker-label.ql-active,
.ql-toolbar.ql-snow .ql-picker-item:hover { color: #fff; }
.ql-toolbar.ql-snow button:hover .ql-stroke, .ql-toolbar.ql-snow button.ql-active .ql-stroke,
.ql-toolbar.ql-snow button:hover .ql-fill, .ql-toolbar.ql-snow button.ql-active .ql-fill { stroke: #fff; }
.ql-snow .ql-tooltip { background-color: var(--cw-panel); color: var(--cw-text); border-color: var(--cw-border); box-shadow: 0 2px 8px rgba(0,0,0,.4); }
.ql-snow .ql-tooltip input[type="text"] { background-color: var(--cw-bg); color: var(--cw-text); border-color: var(--cw-border); }
.attachment-list:not(:empty) { padding: .4rem 0; display: flex; flex-wrap: wrap; gap: .4rem; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<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>
<form id="composeForm" method="POST" action="/webmail/mail/compose" enctype="multipart/form-data" class="compose-shell">
<input type="hidden" name="in_reply_to" value="{{.in_reply_to}}">
<input type="hidden" name="draft_id" value="{{.draft_id}}">
<div class="compose-actionbar">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-send me-1"></i>Send</button>
<button type="submit" formaction="/webmail/mail/save-draft" formnovalidate class="btn btn-outline-light btn-sm"><i class="bi bi-save2 me-1"></i>Save</button>
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
<div class="ms-auto d-flex align-items-center gap-2">
{{if .send_as_options}}
<select class="form-select form-select-sm from-select" name="from">
<option value="{{.mailbox.Email}}">{{.mailbox.Email}}</option>
{{range .send_as_options}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
{{else}}
<span class="from-email">{{.mailbox.Email}}</span>
{{end}}
<a href="/webmail/mail/INBOX" class="btn btn-sm btn-outline-light" title="Close"><i class="bi bi-x-lg"></i></a>
</div>
</div>
<div class="compose-fields">
<div class="field-row">
<label class="field-label">To</label>
<input type="text" class="bare-input recipient-input" name="to" value="{{.to}}" placeholder="recipient@example.com, another@example.com" list="recipientSuggestions" autocomplete="off" required>
<span class="cc-bcc-toggle" id="showCc">Cc</span>
<span class="cc-bcc-toggle" id="showBcc">Bcc</span>
</div>
<div class="field-row" id="ccRow" style="display: none;">
<label class="field-label">Cc</label>
<input type="text" class="bare-input recipient-input" name="cc" value="{{.cc}}" list="recipientSuggestions" autocomplete="off">
</div>
<div class="field-row" id="bccRow" style="display: none;">
<label class="field-label">Bcc</label>
<input type="text" class="bare-input recipient-input" name="bcc" value="{{.bcc}}" list="recipientSuggestions" autocomplete="off">
</div>
<datalist id="recipientSuggestions"></datalist>
<div class="field-row subject-row">
<input type="text" class="bare-input" name="subject" value="{{.subject}}" placeholder="Add a subject">
</div>
<div class="crypto-row">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="smime_sign" value="1" id="smime_sign">
<label class="form-check-label" for="smime_sign"><i class="bi bi-pen me-1"></i>Sign (S/MIME)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="pgp_encrypt" value="1" id="pgp_encrypt">
<label class="form-check-label" for="pgp_encrypt"><i class="bi bi-lock me-1"></i>Encrypt (PGP)</label>
</div>
{{if gt (len .smime_identities) 1}}
<div id="smime_identity_row" style="display: none;">
<select class="form-select form-select-sm" name="smime_identity_id">
{{range .smime_identities}}<option value="{{.ID}}">Certificate expiring {{.NotAfter.Format "2006-01-02"}}</option>{{end}}
</select>
</div>
{{end}}
<div id="pgp_recipient_row" style="display: none;">
{{if .pgp_contacts}}
<select class="form-select form-select-sm" name="pgp_recipient_id" multiple size="3" style="min-width: 220px;">
{{range .pgp_contacts}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}{{.Email}}{{end}} — {{.Fingerprint}}</option>{{end}}
</select>
{{else}}
<span>No PGP contacts yet — add one on the Certs page.</span>
{{end}}
</div>
<a href="/webmail/certs" class="ms-auto text-muted">Manage certificates</a>
</div>
<div id="attachment_list" class="attachment-list"></div>
</div>
<div class="editor-wrap" id="editorWrap">
<div id="editor"></div>
<textarea name="body_html" style="display: none;"></textarea>
</div>
<div id="body_html_seed" style="display: none;">{{.body_html}}</div>
</form>
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/quill/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
// Quill's default clipboard module already turns a pasted screenshot into an
// embedded base64 <img> — that's what makes "paste a screenshot like Outlook"
// work with no extra code here.
var quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }],
[{ size: ['small', false, 'large', 'huge'] }],
['link', 'image'],
['clean'],
],
},
});
(function() {
const seed = document.getElementById('body_html_seed');
if (seed && seed.innerHTML.trim()) {
quill.root.innerHTML = seed.innerHTML;
// Reply/forward seeds start with an empty line above the quoted
// original (see composeCursorHome, webmail_compose.go) — put the
// cursor there so typing starts above the quote, not inside or after it.
quill.setSelection(0, 0);
quill.focus();
}
})();
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
// Cc) — clicking a toggle reveals its row and focuses the field, same as
// clicking "Cc"/"Bcc" in a real mail client.
(function() {
function wireToggle(toggleId, rowId, fieldName) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
const field = row.querySelector('[name="' + fieldName + '"]');
if (field.value.trim()) {
row.style.display = 'flex';
toggle.style.display = 'none';
} else {
toggle.addEventListener('click', function() {
row.style.display = 'flex';
toggle.style.display = 'none';
field.focus();
});
}
}
wireToggle('showCc', 'ccRow', 'cc');
wireToggle('showBcc', 'bccRow', 'bcc');
})();
// Attachments accumulate across multiple picks/drops instead of the native
// file input's default "replace the whole selection" behavior — a DataTransfer
// is the only way to build an editable FileList, so it's kept as the source of
// truth and re-assigned onto the real (hidden) input before every render.
// Drag-and-drop works anywhere over the message area, matching a real client —
// there's no dedicated dropzone box taking up space when empty.
(function() {
const form = document.getElementById('composeForm');
const dropTarget = document.getElementById('editorWrap');
const attachBtn = document.getElementById('attachBtn');
const input = document.getElementById('attachment_input');
const list = document.getElementById('attachment_list');
let staged = new DataTransfer();
function render() {
input.files = staged.files;
list.innerHTML = '';
Array.from(staged.files).forEach(function(file, i) {
const chip = document.createElement('span');
chip.className = 'badge text-bg-secondary d-flex align-items-center gap-1 py-2 px-2';
chip.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-close btn-close-white ms-1';
remove.style.fontSize = '0.65rem';
remove.setAttribute('aria-label', 'Remove');
remove.addEventListener('click', function() { removeFile(i); });
chip.appendChild(remove);
list.appendChild(chip);
});
}
function addFiles(fileList) {
Array.from(fileList).forEach(function(file) { staged.items.add(file); });
render();
}
function removeFile(index) {
const next = new DataTransfer();
Array.from(staged.files).forEach(function(file, i) { if (i !== index) next.items.add(file); });
staged = next;
render();
}
attachBtn.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() { addFiles(input.files); });
form.addEventListener('dragover', function(e) { e.preventDefault(); dropTarget.classList.add('drag-over'); });
form.addEventListener('dragleave', function(e) { if (e.target === form) dropTarget.classList.remove('drag-over'); });
form.addEventListener('drop', function(e) {
e.preventDefault();
dropTarget.classList.remove('drag-over');
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});
window.__composeAttachmentCount = function() { return staged.files.length; };
})();
// The identity picker (when there's more than one S/MIME certificate) only
// matters for signing — encrypting alone never touches it.
(function() {
const signCheckbox = document.getElementById('smime_sign');
const identityRow = document.getElementById('smime_identity_row');
if (!identityRow) return;
function update() { identityRow.style.display = signCheckbox.checked ? '' : 'none'; }
signCheckbox.addEventListener('change', update);
update();
})();
// The recipient-key picker only matters for encrypting.
(function() {
const encryptCheckbox = document.getElementById('pgp_encrypt');
const recipientRow = document.getElementById('pgp_recipient_row');
function update() { recipientRow.style.display = encryptCheckbox.checked ? '' : 'none'; }
encryptCheckbox.addEventListener('change', update);
update();
})();
// Recipient autocomplete: suggests addresses this mailbox has exchanged mail
// with, matching the fragment being typed after the last comma (a
// To/Cc/Bcc field holds a comma-separated address list, so only the
// in-progress fragment should be matched/replaced, not the whole value).
(function() {
const datalist = document.getElementById('recipientSuggestions');
let debounceTimer = null;
document.querySelectorAll('.recipient-input').forEach(function(input) {
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
const value = input.value;
const splitAt = value.lastIndexOf(',') + 1;
const fragment = value.slice(splitAt).trim();
if (!fragment) { datalist.innerHTML = ''; return; }
debounceTimer = setTimeout(function() {
fetch('/webmail/mail/recipients?q=' + encodeURIComponent(fragment))
.then(function(r) { return r.json(); })
.then(function(suggestions) {
datalist.innerHTML = '';
const prefix = value.slice(0, splitAt);
(suggestions || []).forEach(function(addr) {
const opt = document.createElement('option');
opt.value = (prefix ? prefix + ' ' : '') + addr;
datalist.appendChild(opt);
});
})
.catch(function() {});
}, 200);
});
});
})();
// Draft autosave: periodically saves in the background if anything's
// changed since the last autosave, reusing the existing Save Draft endpoint
// exactly as a manual click would submit it — no backend change needed. The
// browser follows the redirect to /webmail/mail/compose?draft=ID&folder=Drafts,
// and that URL's draft param becomes the new draft_id so the next autosave (or
// manual Send) updates the same draft instead of creating a new one.
// Deliberately text-only: attachments aren't re-uploaded on every tick (a
// real Save or Send still includes them) to avoid repeatedly re-sending
// large file data in the background.
(function() {
const form = document.getElementById('composeForm');
const draftIdInput = document.querySelector('[name="draft_id"]');
const status = document.getElementById('autosaveStatus');
let lastSaved = null;
function snapshot() {
return JSON.stringify({
to: document.querySelector('[name="to"]').value,
cc: document.querySelector('[name="cc"]').value,
bcc: document.querySelector('[name="bcc"]').value,
subject: document.querySelector('[name="subject"]').value,
body: quill.root.innerHTML,
});
}
async function autosave() {
const current = snapshot();
if (current === lastSaved) return;
const subject = document.querySelector('[name="subject"]').value.trim();
const to = document.querySelector('[name="to"]').value.trim();
if (!to && !subject && !quill.getText().trim()) return; // nothing worth saving yet
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
const formData = new FormData(form);
formData.delete('attachments');
try {
const resp = await fetch('/webmail/mail/save-draft', { method: 'POST', body: formData });
if (resp.ok) {
const draftId = new URL(resp.url).searchParams.get('draft');
if (draftId) draftIdInput.value = draftId;
lastSaved = current;
if (status) {
status.textContent = 'Saved ' + new Date().toLocaleTimeString();
status.style.display = '';
}
}
} catch (e) { /* offline or a transient error — retried next interval */ }
}
setInterval(autosave, 30000);
})();
// Enter in a single-line field (To/Cc/Bcc/Subject) implicitly submits the
// form — that's how a stray keypress used to send a blank email.
['to', 'cc', 'bcc', 'subject'].forEach(function(name) {
const el = document.querySelector('[name="' + name + '"]');
if (el) {
el.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); }
});
}
});
document.getElementById('composeForm').addEventListener('submit', function(e) {
document.querySelector('[name="body_html"]').value = quill.root.innerHTML;
// Save (formaction=save-draft) deliberately skips the subject/body-required
// guard below — a draft can be incomplete by definition.
const isDraftSave = e.submitter && e.submitter.getAttribute('formaction') === '/webmail/mail/save-draft';
if (isDraftSave) return;
const subject = document.querySelector('[name="subject"]').value.trim();
const body = quill.getText().trim();
const hasAttachments = window.__composeAttachmentCount && window.__composeAttachmentCount() > 0;
if (!subject) {
e.preventDefault();
alert('Please add a subject before sending.');
return;
}
if (!body && !hasAttachments) {
e.preventDefault();
alert('Please write a message or add an attachment before sending.');
return;
}
});
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,122 @@
{{define "compose_widget"}}
<div id="composePopup" class="card shadow" style="display: none; position: fixed; z-index: 1080; min-width: 320px; min-height: 200px; resize: none; overflow: hidden; background-color: #2d2d2d; border: 1px solid #404040;">
<div id="composePopupHandle" class="card-header d-flex justify-content-between align-items-center py-1" style="cursor: move; user-select: none;">
<span class="small"><i class="bi bi-pencil-square me-1"></i>Compose</span>
<div>
<button type="button" id="composePopupClose" class="btn btn-sm btn-outline-light border-0 py-0 px-2" title="Close"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<iframe id="composePopupFrame" style="border: 0; width: 100%; flex: 1 1 auto;"></iframe>
<div id="composePopupResize" style="position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize;"></div>
</div>
<script>
// A floating window over the existing /webmail/mail/compose page (loaded as-is
// in an iframe) — Outlook-style compose without rewriting compose itself as a
// modal. Position/size persist in localStorage so it reopens where it was left.
(function() {
const POS_KEY = 'webmail_compose_popup_pos';
const popup = document.getElementById('composePopup');
const handle = document.getElementById('composePopupHandle');
const frame = document.getElementById('composePopupFrame');
const resizeHandle = document.getElementById('composePopupResize');
const closeBtn = document.getElementById('composePopupClose');
function defaultRect() {
const w = Math.min(720, window.innerWidth - 40);
const h = Math.min(600, window.innerHeight - 40);
return { top: Math.max(20, (window.innerHeight - h) / 2), left: Math.max(20, (window.innerWidth - w) / 2), width: w, height: h };
}
function clampRect(r) {
const width = Math.max(320, Math.min(r.width, window.innerWidth - 20));
const height = Math.max(200, Math.min(r.height, window.innerHeight - 20));
const left = Math.max(0, Math.min(r.left, window.innerWidth - width));
const top = Math.max(0, Math.min(r.top, window.innerHeight - height));
return { top, left, width, height };
}
function loadRect() {
try {
const saved = JSON.parse(localStorage.getItem(POS_KEY));
if (saved && typeof saved.top === 'number') return clampRect(saved);
} catch (e) { /* fall through to default */ }
return defaultRect();
}
function saveRect(r) {
localStorage.setItem(POS_KEY, JSON.stringify(r));
}
function applyRect(r) {
popup.style.top = r.top + 'px';
popup.style.left = r.left + 'px';
popup.style.width = r.width + 'px';
popup.style.height = r.height + 'px';
}
function currentRect() {
return {
top: parseFloat(popup.style.top) || 0,
left: parseFloat(popup.style.left) || 0,
width: parseFloat(popup.style.width) || 0,
height: parseFloat(popup.style.height) || 0,
};
}
window.openCompose = function(url) {
applyRect(loadRect());
popup.style.display = 'flex';
popup.style.flexDirection = 'column';
frame.src = url;
};
function closePopup() {
popup.style.display = 'none';
frame.src = 'about:blank';
}
closeBtn.addEventListener('click', closePopup);
// The compose form navigates the iframe on success (send/save-draft both
// redirect away from /mail/compose) — treat that as "done": close the popup
// and refresh the page underneath so the new Sent/Drafts entry shows up.
frame.addEventListener('load', function() {
let path;
try { path = frame.contentWindow.location.pathname; } catch (e) { return; }
if (frame.src === 'about:blank' || path.indexOf('/webmail/mail/compose') !== -1) return;
closePopup();
window.location.reload();
});
let dragOffset = null;
handle.addEventListener('mousedown', function(e) {
if (e.target.closest('button')) return;
const r = popup.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
e.preventDefault();
});
let resizing = false;
resizeHandle.addEventListener('mousedown', function(e) {
resizing = true;
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (dragOffset) {
const r = clampRect({ top: e.clientY - dragOffset.y, left: e.clientX - dragOffset.x, width: currentRect().width, height: currentRect().height });
applyRect(r);
} else if (resizing) {
const r = popup.getBoundingClientRect();
const rect = clampRect({ top: r.top, left: r.left, width: e.clientX - r.left, height: e.clientY - r.top });
applyRect(rect);
}
});
document.addEventListener('mouseup', function() {
if (dragOffset || resizing) saveRect(currentRect());
dragOffset = null;
resizing = false;
});
})();
</script>
{{end}}
@@ -0,0 +1,282 @@
{{define "webmail_folder.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>{{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/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; }
.folder-link.active { background-color: #0d6efd; color: #fff !important; }
.msg-unread { font-weight: 600; }
.msg-row { cursor: grab; }
.msg-row.dragging { opacity: 0.4; }
.folder-link.drop-hover { background-color: #0d6efd; color: #fff !important; outline: 2px dashed #6ea8fe; outline-offset: -2px; }
.folder-unread-badge { font-size: .7rem; }
.msg-row-older { display: none; }
.msg-group-toggle { cursor: pointer; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<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>
</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-fluid pb-5">
<div class="row">
<div class="col-lg-2 mb-4">
<div class="card">
<div class="card-body p-2">
<form method="get" action="/webmail/mail/search" class="mb-2">
<div class="input-group input-group-sm">
<input type="search" name="q" id="mailSearchInput" class="form-control" placeholder="Search all mail" value="{{.search_query}}">
<button type="submit" class="btn btn-outline-light"><i class="bi bi-search"></i></button>
</div>
</form>
<div class="list-group list-group-flush">
{{$active := .active_folder}}
{{$unread := .unread_counts}}
{{range .folders}}
<div class="d-flex align-items-center folder-row">
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
{{$n := index $unread .}}
{{if $n}}<span class="badge bg-primary rounded-pill folder-unread-badge">{{$n}}</span>{{end}}
</a>
{{if not (isStandardFolder .)}}
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder &quot;{{.}}&quot;? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
</form>
{{end}}
</div>
{{end}}
</div>
<hr class="my-2">
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
</form>
</div>
</div>
</div>
<div class="col-lg-10 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{{if .search_query}}<i class="bi bi-search me-2"></i>Search results for &ldquo;{{.search_query}}&rdquo;
{{else}}<i class="bi bi-folder2-open me-2"></i>{{.active_folder}}{{end}}
</h5>
<small class="text-muted">{{.total}} message{{if ne .total 1}}s{{end}}</small>
</div>
<div class="card-body p-0">
{{if .messages}}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
{{if not .search_query}}<th>{{if eq .active_folder "Sent"}}To{{else}}From{{end}}</th>{{else}}<th>From / To</th>{{end}}
<th>Subject</th>
{{if .search_query}}<th>Folder</th>{{end}}
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{{$folders := .folders}}
{{$showFolderCol := .search_query}}
{{range .messages}}
{{$rowHref := printf "/webmail/mail/%s/%d" .Folder .ID}}
{{if eq .Folder "Drafts"}}{{$rowHref = printf "/webmail/mail/compose?draft=%d&folder=Drafts" .ID}}{{end}}
<tr class="{{if .Unread}}msg-unread{{end}} msg-row{{if .Collapsed}} msg-row-older{{end}}" draggable="true" data-uid="{{.ID}}" data-folder="{{.Folder}}">
<td><a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if eq .Folder "Sent"}}{{if .CachedTo}}{{.CachedTo}}{{else}}(no recipient){{end}}{{else}}{{.CachedFrom}}{{end}}</a></td>
<td>
<a class="text-reset text-decoration-none" href="{{$rowHref}}">{{if .CachedSubject}}{{.CachedSubject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</a>
{{if gt .GroupExtra 0}}<span class="badge bg-secondary msg-group-toggle" data-group-toggle="{{.ID}}">+{{.GroupExtra}} more</span>{{end}}
</td>
{{if $showFolderCol}}<td><small class="text-muted">{{.Folder}}</small></td>{{end}}
<td><small class="text-muted">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</small></td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/move" class="d-inline-flex">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;" onchange="this.form.submit()">
<option value="">Move to&hellip;</option>
{{$rowFolder := .Folder}}
{{range $folders}}{{if ne . $rowFolder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
</form>
<form method="post" action="/webmail/mail/{{.Folder}}/{{.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .Folder "Trash"}}Delete permanently{{else}}Move to Trash{{end}}" data-confirm="{{if eq .Folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if or .has_prev .has_next}}
<div class="d-flex justify-content-between p-3">
{{if .has_prev}}<a href="?page={{sub .page 1}}" class="btn btn-outline-secondary btn-sm">&laquo; Newer</a>{{else}}<span></span>{{end}}
{{if .has_next}}<a href="?page={{add .page 1}}" class="btn btn-outline-secondary btn-sm">Older &raquo;</a>{{end}}
</div>
{{end}}
{{else}}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">No messages in {{.active_folder}}</h5>
</div>
{{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>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
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();
}
});
});
});
// Drag a message row onto a folder in the sidebar to move it there — a
// shortcut for the same "Move to..." dropdown every row already has. Each
// row carries its OWN folder (data-folder) rather than assuming the page's
// active folder, since a search result can span multiple folders.
(function() {
let draggedUID = null;
let draggedFolder = null;
document.querySelectorAll('.msg-row').forEach(function(row) {
row.addEventListener('dragstart', function() {
draggedUID = row.dataset.uid;
draggedFolder = row.dataset.folder;
row.classList.add('dragging');
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
});
});
document.querySelectorAll('.folder-link').forEach(function(link) {
link.addEventListener('dragover', function(e) {
if (!draggedUID || link.dataset.folder === draggedFolder) return;
e.preventDefault();
link.classList.add('drop-hover');
});
link.addEventListener('dragleave', function() {
link.classList.remove('drop-hover');
});
link.addEventListener('drop', async function(e) {
e.preventDefault();
link.classList.remove('drop-hover');
const targetFolder = link.dataset.folder;
if (!draggedUID || !targetFolder || targetFolder === draggedFolder) return;
const body = new URLSearchParams();
body.set('target_folder', targetFolder);
await fetch(`/webmail/mail/${draggedFolder}/${draggedUID}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
window.location.reload();
});
});
})();
// "+N more" toggle for a collapsed same-subject run — reveals every
// immediately-following row marked as part of that group, self-terminating
// at the first row that isn't (no need to track the count client-side).
document.querySelectorAll('[data-group-toggle]').forEach(function(badge) {
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
let sib = badge.closest('tr').nextElementSibling;
while (sib && sib.classList.contains('msg-row-older')) {
sib.style.display = '';
sib = sib.nextElementSibling;
}
badge.style.display = 'none';
});
});
</script>
</body>
</html>
{{end}}
+3 -2
View File
@@ -5,8 +5,8 @@
<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">
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/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%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-inbox-fill" style="font-size: 2.5rem;"></i>
@@ -5,8 +5,8 @@
<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">
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/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%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container login-card">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill" style="font-size: 2.5rem;"></i>
@@ -0,0 +1,201 @@
{{define "webmail_message.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>{{.parsed.Header.Subject}} - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<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>
</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="d-flex justify-content-between align-items-center mb-3">
<a href="/webmail/mail/{{.active_folder}}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to {{.active_folder}}</a>
<div class="btn-group btn-group-sm">
<button type="button" id="replyBtn" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply me-1"></i>Reply</button>
<button type="button" id="replyAllBtn" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-reply-all me-1"></i>Reply All</button>
<button type="button" id="forwardBtn" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary"><i class="bi bi-arrow-right me-1"></i>Forward</button>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-2">{{if .parsed.Header.Subject}}{{.parsed.Header.Subject}}{{else}}<span class="text-muted">(no subject)</span>{{end}}</h5>
{{if or .smime.Signed .smime.Encrypted}}
<div class="mb-2">
{{if .smime.Encrypted}}
{{if .smime.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>Encrypted &amp; decrypted</span>
{{else}}<span class="badge bg-danger" title="{{.smime.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>Encrypted — could not decrypt</span>{{end}}
{{end}}
{{if .smime.Signed}}
{{if .smime.SignatureOK}}<span class="badge bg-success" title="{{.smime.SignerEmail}}"><i class="bi bi-patch-check-fill me-1"></i>Signature verified{{if .smime.SignerEmail}} ({{.smime.SignerEmail}}){{end}}</span>
{{else}}<span class="badge bg-danger" title="{{.smime.SignatureErr}}"><i class="bi bi-exclamation-triangle-fill me-1"></i>Signature invalid</span>{{end}}
{{end}}
</div>
{{end}}
{{if .pgp.Encrypted}}
<div class="mb-2">
{{if .pgp.Decrypted}}<span class="badge bg-success"><i class="bi bi-unlock-fill me-1"></i>PGP encrypted &amp; decrypted</span>
{{else if .pgp.NeedsUnlock}}<span class="badge bg-warning text-dark"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — enter your passphrase to decrypt</span>
{{else}}<span class="badge bg-danger" title="{{.pgp.DecryptErr}}"><i class="bi bi-lock-fill me-1"></i>PGP encrypted — could not decrypt</span>{{end}}
</div>
{{if .pgp.NeedsUnlock}}
<form method="post" action="/webmail/pgp/unlock" class="row g-2 align-items-end">
<input type="hidden" name="next" value="{{.message_url}}">
<div class="col-auto">
<select class="form-select form-select-sm" name="identity_id">
{{range .pgp.Identities}}<option value="{{.ID}}">{{if .Label}}{{.Label}}{{else}}Key{{end}} ({{.Fingerprint}})</option>{{end}}
</select>
</div>
<div class="col-auto">
<input type="password" class="form-control form-control-sm" name="passphrase" placeholder="Passphrase" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-warning btn-sm">Unlock &amp; Decrypt</button>
</div>
</form>
{{end}}
{{end}}
<div class="small text-muted">
<div><strong>From:</strong> {{.parsed.Header.From}}</div>
<div><strong>To:</strong> {{.parsed.Header.To}}</div>
{{if .parsed.Header.Cc}}<div><strong>Cc:</strong> {{.parsed.Header.Cc}}</div>{{end}}
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
</div>
</div>
<div class="card-body">
{{if .html_body}}
<div class="msg-body-html">{{.html_body}}</div>
{{else if .parsed.TextBody}}
<div class="msg-body-text">{{.parsed.TextBody}}</div>
{{else}}
<p class="text-muted mb-0">(empty message body)</p>
{{end}}
{{if .parsed.Attachments}}
<hr>
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
<div class="list-group">
{{$folder := .active_folder}}
{{$uid := .uid}}
{{range $i, $att := .parsed.Attachments}}
<a href="/webmail/mail/{{$folder}}/{{$uid}}/attachment/{{$i}}" class="list-group-item list-group-item-action bg-transparent text-white d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-earmark me-2"></i>{{$att.Filename}}</span>
<i class="bi bi-download"></i>
</a>
{{end}}
</div>
{{end}}
</div>
</div>
<div class="card">
<div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-2">
<select name="target_folder" class="form-select form-select-sm" style="width: auto;">
<option value="">Move to&hellip;</option>
{{$folder := .active_folder}}
{{range .folders}}{{if ne . $folder}}<option value="{{.}}">{{.}}</option>{{end}}{{end}}
</select>
<button type="submit" class="btn btn-outline-secondary btn-sm">Move</button>
</form>
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}</button>
</form>
</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>
{{template "compose_widget" .}}
{{template "webmail_shortcuts" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
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();
}
});
});
});
</script>
</body>
</html>
{{end}}
@@ -5,8 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up two-factor authentication - 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">
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; }
.setup-card { max-width: 480px; margin: 0 auto; width: 100%; }
@@ -14,6 +14,7 @@
</style>
</head>
<body>
{{template "csrf_script" .}}
<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">
@@ -55,7 +56,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 6000}).show(); });
+236
View File
@@ -0,0 +1,236 @@
{{define "webmail_rules.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>Filter Rules - Webmail</title>
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/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>
{{template "csrf_script" .}}
<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>
<div class="navbar-nav flex-row gap-2 ms-auto">
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
<a href="/webmail/rules" class="btn btn-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
<form method="post" action="/webmail/logout" class="d-inline">
<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>
</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="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-funnel me-2"></i>Filter Rules</h4>
</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 folder instead of INBOX — check <a href="/webmail/mail/INBOX" class="alert-link">Mail</a> once something's 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="/webmail/rules/add">
<div class="row g-2 align-items-end mb-3">
<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">Match</label>
<select class="form-select" name="match_type">
<option value="all">ALL of the following (AND)</option>
<option value="any">ANY of the following (OR)</option>
</select>
</div>
</div>
<div id="conditions_container"></div>
<button type="button" id="add_condition" class="btn btn-outline-light btn-sm mb-3"><i class="bi bi-plus-lg me-1"></i>Add condition</button>
<div class="row g-2 align-items-end">
<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="mark_as_spam">Mark as Spam</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>
</div>
</form>
<template id="condition_row_template">
<div class="row g-2 align-items-end mb-2 condition-row">
<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">
<button type="button" class="btn btn-outline-danger btn-sm remove-condition" title="Remove condition"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</template>
</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>{{ruleSummary .}}</code></td>
<td>
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
{{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="/webmail/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>
</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>
{{template "compose_widget" .}}
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
});
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();
}
});
});
});
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';
});
function addConditionRow() {
const tpl = document.getElementById('condition_row_template');
const container = document.getElementById('conditions_container');
const clone = document.importNode(tpl.content, true);
clone.querySelector('.remove-condition').addEventListener('click', function() {
if (container.children.length > 1) {
this.closest('.condition-row').remove();
}
});
container.appendChild(clone);
}
document.getElementById('add_condition').addEventListener('click', addConditionRow);
addConditionRow();
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,74 @@
{{define "webmail_shortcuts"}}
<style>
.msg-row-selected { outline: 2px solid #6ea8fe; outline-offset: -2px; background-color: rgba(110, 168, 254, 0.12); }
</style>
<script>
// Keyboard shortcuts for the folder list (j/k/Enter/o/c//) and the single-message
// view (c/r/a/f/#). Feature-detects which page it's on by which elements exist,
// so one shared partial covers both without a page-specific flag.
(function() {
function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
document.addEventListener('keydown', function(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isTypingTarget(document.activeElement)) return;
const rows = Array.from(document.querySelectorAll('.msg-row:not(.msg-row-older)'));
const searchInput = document.getElementById('mailSearchInput');
if (e.key === '/') {
if (searchInput) { e.preventDefault(); searchInput.focus(); }
return;
}
if (e.key === 'c' && typeof openCompose === 'function') {
e.preventDefault();
openCompose('/webmail/mail/compose');
return;
}
// --- Folder list: j/k select, Enter/o open, #/Delete trash the selected row.
if (rows.length) {
let idx = rows.findIndex(function(r) { return r.classList.contains('msg-row-selected'); });
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
if (idx >= 0) rows[idx].classList.remove('msg-row-selected');
idx = e.key === 'j' ? Math.min(idx + 1, rows.length - 1) : Math.max(idx - 1, 0);
rows[idx].classList.add('msg-row-selected');
rows[idx].scrollIntoView({ block: 'nearest' });
return;
}
if ((e.key === 'Enter' || e.key === 'o') && idx >= 0) {
e.preventDefault();
const link = rows[idx].querySelector('a[href]');
if (link) window.location = link.href;
return;
}
if ((e.key === '#' || e.key === 'Delete') && idx >= 0) {
e.preventDefault();
const deleteBtn = rows[idx].querySelector('form[action*="/delete"] button[type=submit]');
if (deleteBtn) deleteBtn.click();
return;
}
}
// --- Single-message view: r/a/f reply/reply-all/forward, #/Delete trash.
const replyBtn = document.getElementById('replyBtn');
if (replyBtn) {
if (e.key === 'r') { e.preventDefault(); replyBtn.click(); return; }
if (e.key === 'a') { e.preventDefault(); document.getElementById('replyAllBtn').click(); return; }
if (e.key === 'f') { e.preventDefault(); document.getElementById('forwardBtn').click(); return; }
if (e.key === '#' || e.key === 'Delete') {
e.preventDefault();
const deleteBtn = document.querySelector('form[action*="/delete"] button[type=submit]');
if (deleteBtn) deleteBtn.click();
return;
}
}
});
})();
</script>
{{end}}
@@ -5,14 +5,15 @@
<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">
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
</style>
</head>
<body>
{{template "csrf_script" .}}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6">
@@ -31,7 +32,7 @@
<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>
<a href="/webmail/account" 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>
+61
View File
@@ -0,0 +1,61 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestTOTPSetupRendersRealQRImage guards against a real html/template bug found live:
// html/template's URL-context escaper only allows http/https/mailto schemes for a plain
// string in a src="..." attribute — a data: URI (how the QR code image is embedded, see
// totpSetupBegin) gets silently replaced with "#ZgotmplZ" unless typed as template.URL,
// making the QR code invisible with no server-side error at all.
func TestTOTPSetupRendersRealQRImage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
cookie := loginSession(t, app)
req := httptest.NewRequest(http.MethodPost, Prefix+"/account/totp/setup", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "ZgotmplZ") {
t.Fatal("QR image src was stripped to #ZgotmplZ — qr_data_uri must be typed as template.URL")
}
if !strings.Contains(body, "src=\"data:image/png;base64,") {
t.Fatalf("expected a real data:image/png;base64 QR image src in the response, got: %s", body)
}
}
// TestWebmailTOTPSetupRendersRealQRImage is the mailbox self-service equivalent of the
// admin-side test above — same bug, same fix, in webmailTOTPSetupBegin.
func TestWebmailTOTPSetupRendersRealQRImage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "totpuser@example.com", domains[0].ID, "portal-password-123!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/account/totp/setup", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "ZgotmplZ") {
t.Fatal("QR image src was stripped to #ZgotmplZ — qr_data_uri must be typed as template.URL")
}
if !strings.Contains(body, "src=\"data:image/png;base64,") {
t.Fatalf("expected a real data:image/png;base64 QR image src in the response, got: %s", body)
}
}
+116
View File
@@ -0,0 +1,116 @@
package webui
import (
"net"
"net/http"
"net/netip"
"strings"
"mailgoserver/internal/toolbox"
)
// cloudflareRanges are Cloudflare's published proxy IP ranges (fetched live from
// https://www.cloudflare.com/ips-v4 and /ips-v6 rather than trusted from memory,
// since a stale list here would either wrongly trust an attacker-controlled hop or
// wrongly distrust Cloudflare's own edge) — expanded when "cloudflare" appears in
// the trusted_proxies config value. Cloudflare rotates these occasionally; re-fetch
// and update this list if IP resolution behind Cloudflare ever looks wrong.
var cloudflareRanges = []string{
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
"141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
"197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32",
"2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32",
}
// parseTrustedProxies reads the [Server] trusted_proxies config value — a
// comma-separated list of CIDRs and/or the literal word "cloudflare" — into parsed
// prefixes. Unparseable entries are skipped (logged by the caller) rather than
// failing startup over a typo in a security-adjacent but non-fatal setting.
func parseTrustedProxies(raw string, logger *toolbox.Logger) []netip.Prefix {
var out []netip.Prefix
for _, entry := range strings.Split(raw, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.EqualFold(entry, "cloudflare") {
for _, cidr := range cloudflareRanges {
if p, err := netip.ParsePrefix(cidr); err == nil {
out = append(out, p)
}
}
continue
}
p, err := netip.ParsePrefix(entry)
if err != nil {
// A bare IP (no /mask) is a common typo for "trust this one proxy" —
// accept it as a /32 or /128 host route rather than silently dropping it.
if addr, addrErr := netip.ParseAddr(entry); addrErr == nil {
bits := 32
if addr.Is6() {
bits = 128
}
out = append(out, netip.PrefixFrom(addr, bits))
continue
}
if logger != nil {
logger.Error("trusted_proxies: skipping unparseable entry %q: %v", entry, err)
}
continue
}
out = append(out, p)
}
return out
}
func isTrustedProxy(trusted []netip.Prefix, addr netip.Addr) bool {
for _, p := range trusted {
if p.Contains(addr) {
return true
}
}
return false
}
// requestIP returns the best-effort real client IP for r. Forwarded headers
// (CF-Connecting-IP, X-Forwarded-For, X-Real-IP) are only honored when the direct
// TCP peer (r.RemoteAddr) is itself a configured trusted proxy — otherwise a client
// with no proxy in front of it could simply set these headers itself and spoof any
// IP for every audit log entry and IP-based check in the app. When trusted,
// X-Forwarded-For is walked from the right (the hop closest to us) skipping any
// entries that are themselves trusted proxies, landing on the first untrusted (i.e.
// real client) address — the standard correct algorithm, since the leftmost entry is
// client-supplied and trivially spoofable even through a legitimate proxy.
func (a *App) requestIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer, parseErr := netip.ParseAddr(host)
if parseErr != nil || !isTrustedProxy(a.trustedProxies, peer) {
return host
}
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); cf != "" {
return cf
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
hops := strings.Split(fwd, ",")
for i := len(hops) - 1; i >= 0; i-- {
hop := strings.TrimSpace(hops[i])
if hop == "" {
continue
}
if addr, err := netip.ParseAddr(hop); err == nil && isTrustedProxy(a.trustedProxies, addr) {
continue // another hop we also trust — keep walking left for the real client
}
return hop
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
return host
}
+79
View File
@@ -0,0 +1,79 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRequestIPTrustedProxy(t *testing.T) {
a := &App{trustedProxies: parseTrustedProxies("10.0.0.0/8", nil)}
cases := []struct {
name string
remoteAddr string
headers map[string]string
want string
}{
{
name: "untrusted RemoteAddr ignores X-Forwarded-For entirely",
remoteAddr: "203.0.113.5:12345",
headers: map[string]string{"X-Forwarded-For": "1.2.3.4"},
want: "203.0.113.5", // a client with nothing in front of it can't spoof its own IP
},
{
name: "trusted proxy: X-Forwarded-For honored",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "198.51.100.9"},
want: "198.51.100.9",
},
{
name: "trusted proxy: walks from the right, skipping other trusted hops",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "198.51.100.9, 10.0.0.2"},
want: "198.51.100.9", // 10.0.0.2 is itself trusted (in 10.0.0.0/8) — skip it, land on the real client
},
{
name: "trusted proxy: CF-Connecting-IP preferred over X-Forwarded-For",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"CF-Connecting-IP": "198.51.100.9", "X-Forwarded-For": "attacker-spoofed-should-be-ignored"},
want: "198.51.100.9",
},
{
name: "no trusted_proxies configured: header ignored even from that same peer",
remoteAddr: "203.0.113.5:12345",
headers: map[string]string{"X-Real-IP": "1.2.3.4"},
want: "203.0.113.5",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
for k, v := range tc.headers {
req.Header.Set(k, v)
}
if got := a.requestIP(req); got != tc.want {
t.Errorf("requestIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestParseTrustedProxiesCloudflarePreset(t *testing.T) {
prefixes := parseTrustedProxies("cloudflare", nil)
if len(prefixes) != len(cloudflareRanges) {
t.Fatalf("expected %d cloudflare ranges parsed, got %d", len(cloudflareRanges), len(prefixes))
}
}
func TestParseTrustedProxiesBareIP(t *testing.T) {
prefixes := parseTrustedProxies("192.0.2.10", nil)
if len(prefixes) != 1 {
t.Fatalf("expected 1 prefix, got %d", len(prefixes))
}
if prefixes[0].Bits() != 32 {
t.Fatalf("expected a bare IPv4 to become a /32, got /%d", prefixes[0].Bits())
}
}
-21
View File
@@ -45,27 +45,6 @@ func fetchBody(client *http.Client, url string) string {
return string(b)
}
// requestIP returns the best-effort client IP for an HTTP request — the first hop of
// X-Forwarded-For if present (this app is documented to run behind a reverse proxy),
// falling back to the direct connection's address with its port stripped.
func requestIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.Index(fwd, ","); i >= 0 {
fwd = fwd[:i]
}
if ip := strings.TrimSpace(fwd); ip != "" {
return ip
}
}
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// resolverAt builds a resolver pinned to a specific DNS server, mirroring
// utils.check_dns_record's hardcoded Cloudflare resolver (1.1.1.1), 5s timeout.
func resolverAt(serverIP string) *net.Resolver {
+36
View File
@@ -0,0 +1,36 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestVendoredAssetsServedUnderBothPrefixes confirms Bootstrap/Bootstrap
// Icons/Quill are served locally (no CDN dependency) under both the admin and
// webmail static routes — templates in each tree reference their own prefix.
func TestVendoredAssetsServedUnderBothPrefixes(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
paths := []string{
Prefix + "/static/vendor/bootstrap/css/bootstrap.min.css",
Prefix + "/static/vendor/bootstrap/js/bootstrap.bundle.min.js",
Prefix + "/static/vendor/bootstrap-icons/font/bootstrap-icons.css",
Prefix + "/static/vendor/bootstrap-icons/font/fonts/bootstrap-icons.woff2",
MailboxPrefix + "/static/vendor/bootstrap/css/bootstrap.min.css",
MailboxPrefix + "/static/vendor/quill/quill.js",
MailboxPrefix + "/static/vendor/quill/quill.snow.css",
}
for _, p := range paths {
req := httptest.NewRequest(http.MethodGet, p, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: status=%d", p, rec.Code)
}
if rec.Body.Len() == 0 {
t.Errorf("%s: empty body", p)
}
}
}
+4 -4
View File
@@ -150,7 +150,7 @@ func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
return
}
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey added: "+name)
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "Passkey added: "+name)
writeJSON(w, http.StatusOK, M{"success": true})
}
@@ -159,7 +159,7 @@ func (a *App) passkeyRemove(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil {
setFlash(w, "error", "Could not remove passkey")
} else {
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey removed")
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "Passkey removed")
setFlash(w, "success", "Passkey removed")
}
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
@@ -230,7 +230,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
}
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
clearWebauthnSession(w)
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Passkey verification failed")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), false, "Passkey verification failed")
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
return
}
@@ -241,7 +241,7 @@ func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
return
}
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (passkey)")
_ = a.DB.LogAuthAttempt("admin_login", user.Username, a.requestIP(r), true, "Login successful (passkey)")
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
writeJSON(w, http.StatusOK, M{"success": true})
+22 -19
View File
@@ -3,6 +3,7 @@ package webui
import (
"bytes"
"encoding/base64"
"html/template"
"image/png"
"net/http"
"strings"
@@ -55,32 +56,32 @@ func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
if !db.CheckPassword(current, mbox.PasswordHash) {
setFlash(w, "error", "Current password is incorrect")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", 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)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if newPassword != confirm {
setFlash(w, "error", "New passwords don't match")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", 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)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "Password updated")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
@@ -88,12 +89,12 @@ func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
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)
http.Redirect(w, r, MailboxPrefix+"/account", 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)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
img, err := key.Image(256, 256)
@@ -104,7 +105,9 @@ func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
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})
// See totpSetupBegin's matching comment in account.go — data: URIs need to be
// typed as template.URL or html/template silently strips them to "#ZgotmplZ".
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)})
}
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
@@ -112,17 +115,17 @@ func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
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)
http.Redirect(w, r, MailboxPrefix+"/account", 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)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator enabled")
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator enabled")
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
@@ -130,10 +133,10 @@ func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator disabled")
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator disabled")
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
@@ -150,16 +153,16 @@ func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
hash, err := db.HashPassword(secret)
if err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
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)
http.Redirect(w, r, MailboxPrefix+"/account", 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)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
@@ -170,5 +173,5 @@ func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
} else {
setFlash(w, "success", "App password revoked")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
+524
View File
@@ -0,0 +1,524 @@
package webui
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/mailview"
)
// TestWebmailComposeSendLocalDelivery confirms a composed message reaches another
// local mailbox's INBOX with the right content, and a copy lands in the sender's own
// Sent folder — the core send/receive round trip.
func TestWebmailComposeSendLocalDelivery(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "recipient@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"recipient@example.com"}, "subject": {"Hello there"}, "body_html": {"This is the message body."},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil {
t.Fatal(err)
}
if len(recipientMsgs) != 1 {
t.Fatalf("expected 1 message in recipient's INBOX, got %d", len(recipientMsgs))
}
if recipientMsgs[0].CachedSubject != "Hello there" {
t.Errorf("recipient subject = %q", recipientMsgs[0].CachedSubject)
}
senderSent, err := app.DB.ListMessagesInFolder(senderID, "Sent")
if err != nil {
t.Fatal(err)
}
if len(senderSent) != 1 {
t.Fatalf("expected 1 message in sender's Sent folder, got %d", len(senderSent))
}
// Recipient can actually read it via the message view.
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), "This is the message body.") {
t.Error("expected the message body in the rendered view")
}
// It's also recorded in the admin email log for visibility.
logs, _ := app.DB.ListEmailLogsPage(0, 10)
found := false
for _, l := range logs {
if l.Subject == "Hello there" && l.MailFrom == "sender@example.com" {
found = true
}
}
if !found {
t.Error("expected the webmail send to show up in the admin email log")
}
}
// TestWebmailMessageHTMLBodyIsSanitized confirms a malicious HTML body (e.g. from a
// received message, not something webmail's own plain-text compose can produce) never
// reaches the page unsanitized — this is the actual stored-XSS defense.
func TestWebmailMessageHTMLBodyIsSanitized(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "victim@example.com", domains[0].ID, "victim-password-1!")
raw := "From: attacker@evil.example\r\nTo: victim@example.com\r\nSubject: gotcha\r\n" +
"Content-Type: text/html\r\n\r\n" +
`<p>hello</p><script>alert(document.cookie)</script><img src=x onerror="alert(1)">`
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "x@example.com", "attacker@evil.example", "gotcha")
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", rec.Code)
}
body := rec.Body.String()
// The page legitimately has its own <script> tags (Bootstrap/toast JS) — check
// for the actual injected payload surviving, not the literal substring "<script>".
if strings.Contains(body, "alert(document.cookie)") || strings.Contains(body, "onerror=") {
t.Error("HTML body was not sanitized — script/event-handler survived into the rendered page")
}
if !strings.Contains(body, "<p>hello</p>") {
t.Error("expected the safe formatting to survive sanitization")
}
}
// TestWebmailAttachmentRoundTrip confirms a file attached during compose survives
// send, local delivery, and download with identical bytes.
func TestWebmailAttachmentRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "sender2@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "recipient2@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("to", "recipient2@example.com")
mw.WriteField("subject", "With attachment")
mw.WriteField("body_html", "see attached")
fw, err := mw.CreateFormFile("attachments", "notes.txt")
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("attachment file contents"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send with attachment: status=%d body=%s", rec.Code, rec.Body.String())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
uid := recipientMsgs[0].ID
recipientCookie := webmailLoginSession(t, app, recipientID)
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10)+"/attachment/0", nil)
dlReq.AddCookie(recipientCookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download attachment: status=%d", dlRec.Code)
}
if got := dlRec.Body.String(); got != "attachment file contents" {
t.Errorf("downloaded attachment = %q, want original content", got)
}
}
// TestWebmailComposeSendMultipleAttachments confirms sending 2+ files under the
// "attachments" field name — the shape the accumulating dropzone picker in
// webmail_compose.html produces — delivers all of them, not just the first. The
// server-side loop over r.MultipartForm.File["attachments"] already handled this
// before the dropzone UI existed; this closes the test-coverage gap that let "only
// one attachment works" go unnoticed (it was a frontend picker limitation, not a
// server one — see the accumulating DataTransfer-backed picker in Milestone C).
func TestWebmailComposeSendMultipleAttachments(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "multisender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "multirecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("to", "multirecip@example.com")
mw.WriteField("subject", "Three files")
mw.WriteField("body_html", "see attached")
for i, name := range []string{"a.txt", "b.txt", "c.txt"} {
fw, err := mw.CreateFormFile("attachments", name)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("contents of file " + strconv.Itoa(i)))
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
recipientMsgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(recipientMsgs) != 1 {
t.Fatalf("recipient INBOX: got %d messages, err=%v", len(recipientMsgs), err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view message: status=%d", viewRec.Code)
}
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
if !strings.Contains(viewRec.Body.String(), name) {
t.Errorf("expected attachment %q listed on the message page", name)
}
}
if !strings.Contains(viewRec.Body.String(), "see attached") {
t.Errorf("expected the message body rendered alongside the attachments, got: %s", viewRec.Body.String())
}
for i, name := range []string{"a.txt", "b.txt", "c.txt"} {
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(recipientMsgs[0].ID, 10)+"/attachment/"+strconv.Itoa(i), nil)
dlReq.AddCookie(recipientCookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK {
t.Fatalf("download attachment %d (%s): status=%d", i, name, dlRec.Code)
}
want := "contents of file " + strconv.Itoa(i)
if got := dlRec.Body.String(); got != want {
t.Errorf("attachment %d content = %q, want %q", i, got, want)
}
}
}
// TestWebmailComposeRejectsEmptySend confirms the server-side guard (not just the
// client-side JS, which a test can't exercise) refuses to send a message with no
// subject, and separately one with no body and no attachments.
func TestWebmailComposeRejectsEmptySend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "emptysender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "emptyrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
send := func(t *testing.T, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// A rejected send redisplays the compose form (status 200) with the posted
// content still filled in, rather than redirecting to a blank one.
noSubject := url.Values{"to": {"emptyrecip@example.com"}, "subject": {""}, "body_html": {"hello"}}
rec := send(t, noSubject)
if rec.Code != http.StatusOK {
t.Fatalf("no-subject send: status=%d", rec.Code)
}
noBody := url.Values{"to": {"emptyrecip@example.com"}, "subject": {"hi"}, "body_html": {" "}}
rec = send(t, noBody)
if rec.Code != http.StatusOK {
t.Fatalf("no-body send: status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected neither blank send delivered, got %d messages (err=%v)", len(msgs), err)
}
}
// TestWebmailComposeHTMLBodyRoundTrip confirms an HTML compose body (what Quill
// submits) survives send/delivery as a proper multipart/alternative — both the
// formatted HTML and a derived plain-text fallback reach the recipient — and renders
// with its formatting intact on the message page.
func TestWebmailComposeHTMLBodyRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "htmlsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "htmlrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"htmlrecip@example.com"}, "subject": {"Formatted"},
"body_html": {"<p><strong>Bold</strong> and <em>italic</em> text.</p>"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
parsed, err := mailview.Parse(raw)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(parsed.HTMLBody, "<strong>Bold</strong>") {
t.Errorf("expected the HTML body to survive formatting, got %q", parsed.HTMLBody)
}
if !strings.Contains(parsed.TextBody, "Bold and italic text") {
t.Errorf("expected a plain-text fallback part derived from the HTML, got %q", parsed.TextBody)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), "<strong>Bold</strong>") {
t.Error("expected the formatted HTML rendered on the message page")
}
}
// TestWebmailComposePastedImageSurvivesRoundTrip confirms a pasted screenshot
// (Quill's clipboard module embeds it as a base64 data: URI <img>) survives compose,
// delivery, and sanitize-on-view unchanged — this is what makes "paste a screenshot"
// actually work end to end, not just accepted at compose time.
func TestWebmailComposePastedImageSurvivesRoundTrip(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "imgsender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "imgrecip@example.com", domainID, "recipient-password-1!")
cookie := webmailLoginSession(t, app, senderID)
imgSrc := "data:image/png;base64,aGVsbG8="
form := url.Values{
"to": {"imgrecip@example.com"}, "subject": {"Screenshot"},
"body_html": {`<p>See attached: <img src="` + imgSrc + `"></p>`},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d", viewRec.Code)
}
if !strings.Contains(viewRec.Body.String(), imgSrc) {
t.Errorf("expected the pasted base64 image to survive sanitize-on-view, got body: %s", viewRec.Body.String())
}
}
// TestWebmailComposeReplyPrefill confirms replying prefills To/Subject/quoted body
// from the original message.
func TestWebmailComposeReplyPrefill(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "replier@example.com", domains[0].ID, "replier-password-1!")
raw := "From: original@example.com\r\nTo: replier@example.com\r\nSubject: Original subject\r\nMessage-Id: <orig123@example.com>\r\n\r\noriginal body text"
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "orig123@example.com", "original@example.com", "Original subject")
if err != nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?reply="+strconv.FormatInt(uid, 10)+"&folder=INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("compose reply prefill: status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "original@example.com") {
t.Error("expected the To field prefilled with the original sender")
}
if !strings.Contains(body, "Re: Original subject") {
t.Error("expected the subject prefilled with a Re: prefix")
}
if !strings.Contains(body, "original body text") {
t.Error("expected the original body quoted")
}
// The new message goes ABOVE the quoted original, not mixed into or after it —
// composeCursorHome (webmail_compose.go) prepends an empty line the cursor gets
// placed in (see webmail_compose.html's seed-loading JS), so the seed must start
// with that empty paragraph before the "On ... wrote:" quote line.
seedIdx := strings.Index(body, `id="body_html_seed"`)
quoteIdx := strings.Index(body, "wrote:")
emptyLineIdx := strings.Index(body, "<p><br></p>")
if seedIdx < 0 || quoteIdx < 0 || emptyLineIdx < 0 || !(seedIdx < emptyLineIdx && emptyLineIdx < quoteIdx) {
t.Errorf("expected an empty line before the quoted original (for the new message to go above it), got: %s", body)
}
}
// TestWebmailMoveAndDeleteMessage confirms moving a message changes its folder, and
// deleting from a non-Trash folder moves to Trash first, requiring a second delete to
// actually remove it.
func TestWebmailMoveAndDeleteMessage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
raw := "From: a@example.com\r\nTo: organizer@example.com\r\nSubject: sort me\r\n\r\nbody"
uid, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(raw), "m1@example.com", "a@example.com", "sort me")
if err != nil {
t.Fatal(err)
}
uidStr := strconv.FormatInt(uid, 10)
moveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/INBOX/"+uidStr+"/move", strings.NewReader("target_folder=Work"))
moveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
moveReq.AddCookie(cookie)
moveRec := httptest.NewRecorder()
mux.ServeHTTP(moveRec, moveReq)
if moveRec.Code != http.StatusFound {
t.Fatalf("move: status=%d", moveRec.Code)
}
moved, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || moved == nil || moved.Folder != "Work" {
t.Fatalf("expected message moved to Work, got %+v (err=%v)", moved, err)
}
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Work/"+uidStr+"/delete", nil)
delReq.AddCookie(cookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusFound {
t.Fatalf("delete (to trash): status=%d", delRec.Code)
}
trashed, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || trashed == nil || trashed.Folder != "Trash" {
t.Fatalf("expected message moved to Trash, got %+v (err=%v)", trashed, err)
}
del2Req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/Trash/"+uidStr+"/delete", nil)
del2Req.AddCookie(cookie)
del2Rec := httptest.NewRecorder()
mux.ServeHTTP(del2Rec, del2Req)
if del2Rec.Code != http.StatusFound {
t.Fatalf("delete (permanent): status=%d", del2Rec.Code)
}
gone, err := app.DB.GetMessageByUID(mailboxID, uid)
if err != nil || gone != nil {
t.Fatalf("expected message permanently gone, got %+v (err=%v)", gone, err)
}
}
// TestWebmailMessageAccessControlAcrossMailboxes confirms one mailbox owner can't
// view another mailbox's message by guessing its UID, even in a folder name they
// both happen to have.
func TestWebmailMessageAccessControlAcrossMailboxes(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
ownerID := createTestMailboxWithPassword(t, app, "owner3@example.com", domainID, "owner-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domainID, "attacker-password-1!")
raw := "From: a@example.com\r\nTo: owner3@example.com\r\nSubject: private\r\n\r\nsecret body"
uid, err := app.Mailstore.StoreMessage(ownerID, "INBOX", []byte(raw), "m2@example.com", "a@example.com", "private")
if err != nil {
t.Fatal(err)
}
attackerCookie := webmailLoginSession(t, app, attackerID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
req.AddCookie(attackerCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for another mailbox's message, got %d", rec.Code)
}
}
+812
View File
@@ -0,0 +1,812 @@
package webui
import (
"bytes"
"encoding/base64"
"encoding/json"
"html"
"html/template"
"io"
"mime/multipart"
"net/http"
"net/mail"
"net/textproto"
"strconv"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"mailgoserver/internal/db"
"mailgoserver/internal/mailview"
"mailgoserver/internal/pgp"
"mailgoserver/internal/relay"
"mailgoserver/internal/smime"
"mailgoserver/internal/toolbox"
)
const maxComposeUploadBytes = 25 << 20 // 25MB, matching a typical provider's attachment cap
// composeFormData builds the template data every compose-page render needs
// regardless of why it's rendering (a fresh GET, a reply/forward prefill, or
// redisplaying the form after a failed send) — shared so those three paths can't
// drift out of sync with each other.
func (a *App) composeFormData(mbox *db.Mailbox) M {
aliases, _ := a.DB.ListAliasesForMailbox(mbox.ID)
var sendAsOptions []string
for _, al := range aliases {
if al.CanSendAs && al.IsActive {
sendAsOptions = append(sendAsOptions, al.Email)
}
}
identities, _ := a.DB.ListSMIMEIdentities(mbox.ID)
pgpContacts, _ := a.DB.ListPGPContacts(mbox.ID)
return M{"mailbox": mbox, "send_as_options": sendAsOptions, "smime_identities": identities, "pgp_contacts": pgpContacts}
}
// webmailComposeForm shows the compose page, optionally prefilled for a reply,
// reply-all, or forward (query params: reply=uid&folder=X, replyall=..., forward=...).
func (a *App) webmailComposeForm(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
data := a.composeFormData(mbox)
data["flashes"] = popFlashes(w, r)
q := r.URL.Query()
folder := q.Get("folder")
var uidStr, mode string
switch {
case q.Get("reply") != "":
uidStr, mode = q.Get("reply"), "reply"
case q.Get("replyall") != "":
uidStr, mode = q.Get("replyall"), "replyall"
case q.Get("forward") != "":
uidStr, mode = q.Get("forward"), "forward"
case q.Get("draft") != "":
uidStr, mode = q.Get("draft"), "draft"
}
if mode != "" && folder != "" {
if parsed := a.webmailLoadForPrefill(mbox.ID, folder, int64(atoi(uidStr))); parsed != nil {
switch mode {
case "reply":
data["to"] = parsed.Header.From
data["subject"] = replySubject(parsed.Header.Subject)
data["body_html"] = template.HTML(quoteBodyHTML(parsed))
data["in_reply_to"] = parsed.Header.MessageID
case "replyall":
to, cc := replyAllRecipients(parsed, mbox.Email)
data["to"] = to
data["cc"] = cc
data["subject"] = replySubject(parsed.Header.Subject)
data["body_html"] = template.HTML(quoteBodyHTML(parsed))
data["in_reply_to"] = parsed.Header.MessageID
case "draft":
// Unlike reply/forward, a draft's own To/Cc/Subject/body are reloaded
// as-is (not quoted) — continuing to edit the same message, not
// replying to it. Bcc isn't recoverable: it's deliberately never
// written into the stored message content (see buildEnvelopeHeaders).
data["to"] = parsed.Header.To
data["cc"] = parsed.Header.Cc
data["subject"] = parsed.Header.Subject
body := parsed.HTMLBody
if body == "" && parsed.TextBody != "" {
body = `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(parsed.TextBody) + `</pre>`
}
data["body_html"] = template.HTML(htmlBodyPolicy.Sanitize(body))
data["draft_id"] = uidStr
case "forward":
data["subject"] = forwardSubject(parsed.Header.Subject)
data["body_html"] = template.HTML(forwardBodyHTML(parsed))
}
}
}
a.render(w, r, "webmail_compose.html", data)
}
// webmailRecipientSuggest backs the To/Cc/Bcc autocomplete — addresses this mailbox
// has previously exchanged mail with, matching the current fragment being typed.
func (a *App) webmailRecipientSuggest(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
var suggestions []string
if q != "" {
suggestions, _ = a.DB.SuggestRecipients(mbox.ID, q)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(suggestions)
}
// webmailLoadForPrefill fetches+parses a message for reply/forward prefill, scoped to
// this mailbox and folder — returns nil (silently, no flash) on any failure, since
// the worst case is just an unprefilled compose form, not something worth erroring
// the whole page over.
func (a *App) webmailLoadForPrefill(mailboxID int64, folder string, uid int64) *mailview.Message {
msgRow, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msgRow == nil || msgRow.Folder != folder {
return nil
}
raw, err := a.Mailstore.FetchMessage(mailboxID, uid)
if err != nil {
return nil
}
parsed, err := mailview.Parse(raw)
if err != nil {
return nil
}
return parsed
}
func replySubject(s string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "re:") {
return s
}
return "Re: " + s
}
func forwardSubject(s string) string {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "fwd:") {
return s
}
return "Fwd: " + s
}
// plainBody prefers the parsed message's text body; an HTML-only message can't be
// meaningfully quoted into a plain-text compose box, so it's called out instead.
func plainBody(parsed *mailview.Message) string {
if parsed.TextBody != "" {
return parsed.TextBody
}
if parsed.HTMLBody != "" {
return "(original message was HTML-only — open it in its folder to view)"
}
return ""
}
// quotedBodyHTML renders the original message's body for embedding into a
// reply/forward compose: the original HTML body (sanitized) when present, otherwise
// the plain-text body escaped and wrapped in a <pre> so line breaks survive.
func quotedBodyHTML(parsed *mailview.Message) string {
if parsed.HTMLBody != "" {
return htmlBodyPolicy.Sanitize(parsed.HTMLBody)
}
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0;">` + html.EscapeString(plainBody(parsed)) + `</pre>`
}
// composeCursorHome is an empty paragraph — Quill's own canonical markup for a
// blank line — prepended to reply/forward bodies so the new message has somewhere
// to go ABOVE the quoted original, with the cursor placed there automatically (see
// the body_html_seed handling in webmail_compose.html) instead of landing inside or
// after the quote.
const composeCursorHome = "<p><br></p>"
func quoteBodyHTML(parsed *mailview.Message) string {
return composeCursorHome +
"<p>On " + html.EscapeString(parsed.Header.Date) + ", " + html.EscapeString(parsed.Header.From) + " wrote:</p>" +
`<blockquote style="border-left: 2px solid #999; margin: 0; padding-left: 1em;">` + quotedBodyHTML(parsed) + "</blockquote>"
}
func forwardBodyHTML(parsed *mailview.Message) string {
return composeCursorHome +
"<p>---------- Forwarded message ----------<br>" +
"From: " + html.EscapeString(parsed.Header.From) + "<br>" +
"Date: " + html.EscapeString(parsed.Header.Date) + "<br>" +
"Subject: " + html.EscapeString(parsed.Header.Subject) + "<br>" +
"To: " + html.EscapeString(parsed.Header.To) + "</p>" +
quotedBodyHTML(parsed)
}
// replyAllRecipients puts the original sender in To and everyone else who received
// the original (To+Cc, minus the replying mailbox itself) in Cc — standard
// reply-all semantics.
func replyAllRecipients(parsed *mailview.Message, ownEmail string) (to, cc string) {
seen := map[string]bool{strings.ToLower(ownEmail): true, strings.ToLower(bareAddress(parsed.Header.From)): true}
var ccList []string
for _, addr := range append(splitAddressList(parsed.Header.To), splitAddressList(parsed.Header.Cc)...) {
bare := strings.ToLower(bareAddress(addr))
if bare == "" || seen[bare] {
continue
}
seen[bare] = true
ccList = append(ccList, addr)
}
return parsed.Header.From, strings.Join(ccList, ", ")
}
func splitAddressList(raw string) []string {
addrs, err := mail.ParseAddressList(raw)
if err != nil {
return nil
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.Address
}
return out
}
func bareAddress(raw string) string {
if a, err := mail.ParseAddress(raw); err == nil {
return a.Address
}
return raw
}
func domainOfAddress(addr string) string {
if i := strings.LastIndex(addr, "@"); i >= 0 {
return strings.ToLower(addr[i+1:])
}
return ""
}
type composeAttachment struct {
Filename, ContentType string
Data []byte
}
// buildEnvelopeHeaders returns a composed message's envelope headers — everything
// except the MIME entity's own Content-Type/Content-Transfer-Encoding, which come
// from buildMessageEntity instead. Kept separate so S/MIME's Sign/Encrypt
// (internal/smime) can transform just the entity, never touching From/To/Subject.
// Deliberately never includes a Bcc header (real mail clients never put one in the
// transmitted DATA either) — Bcc recipients still receive the mail via the
// envelope-level recipient list built by the caller, they just don't appear in the
// message content itself, matching standard practice.
func buildEnvelopeHeaders(from string, to, cc []string, subject, messageID, inReplyTo string) []string {
headers := []string{
"Message-ID: <" + messageID + ">",
"Date: " + time.Now().Format(time.RFC1123Z),
"From: " + from,
"To: " + strings.Join(to, ", "),
}
if len(cc) > 0 {
headers = append(headers, "Cc: "+strings.Join(cc, ", "))
}
headers = append(headers, "Subject: "+subject)
if inReplyTo != "" {
headers = append(headers, "In-Reply-To: "+inReplyTo, "References: "+inReplyTo)
}
headers = append(headers, "MIME-Version: 1.0")
return headers
}
// buildBodyEntity builds just the message body part: a flat text/plain part when
// htmlBody is empty, otherwise multipart/alternative (plainText fallback + htmlBody)
// — the standard shape for an HTML-composed email so a plain-text-only mail client
// still gets something readable.
func buildBodyEntity(plainText, htmlBody string) (smime.Entity, error) {
if htmlBody == "" {
return smime.Entity{
Headers: []string{`Content-Type: text/plain; charset="UTF-8"`, "Content-Transfer-Encoding: 8bit"},
Body: []byte(plainText),
}, nil
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
tp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/plain; charset="UTF-8"`}})
if err != nil {
return smime.Entity{}, err
}
if _, err := tp.Write([]byte(plainText)); err != nil {
return smime.Entity{}, err
}
hp, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {`text/html; charset="UTF-8"`}})
if err != nil {
return smime.Entity{}, err
}
if _, err := hp.Write([]byte(htmlBody)); err != nil {
return smime.Entity{}, err
}
if err := mw.Close(); err != nil {
return smime.Entity{}, err
}
return smime.Entity{
Headers: []string{`Content-Type: multipart/alternative; boundary="` + mw.Boundary() + `"`},
Body: buf.Bytes(),
}, nil
}
// headerLinesToMIMEHeader converts an smime.Entity's flat "Name: value" header
// lines into textproto.MIMEHeader, for embedding one entity's headers+body as a
// nested part inside another multipart.Writer (mw.CreatePart wants that shape).
func headerLinesToMIMEHeader(headers []string) textproto.MIMEHeader {
h := textproto.MIMEHeader{}
for _, line := range headers {
if i := strings.Index(line, ":"); i >= 0 {
h.Add(strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]))
}
}
return h
}
// buildMessageEntity builds the MIME entity (Content-Type/CTE headers + body) for a
// composed message: the body (buildBodyEntity — plain, or multipart/alternative when
// htmlBody is set) alone if there are no attachments, otherwise nested as the first
// part of a multipart/mixed alongside each attachment.
func buildMessageEntity(plainText, htmlBody string, attachments []composeAttachment) (smime.Entity, error) {
bodyEntity, err := buildBodyEntity(plainText, htmlBody)
if err != nil {
return smime.Entity{}, err
}
if len(attachments) == 0 {
return bodyEntity, nil
}
var bodyBuf bytes.Buffer
mw := multipart.NewWriter(&bodyBuf)
bodyPart, err := mw.CreatePart(headerLinesToMIMEHeader(bodyEntity.Headers))
if err != nil {
return smime.Entity{}, err
}
if _, err := bodyPart.Write(bodyEntity.Body); err != nil {
return smime.Entity{}, err
}
for _, att := range attachments {
ct := att.ContentType
if ct == "" {
ct = "application/octet-stream"
}
part, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {ct + `; name="` + att.Filename + `"`},
"Content-Disposition": {`attachment; filename="` + att.Filename + `"`},
"Content-Transfer-Encoding": {"base64"},
})
if err != nil {
return smime.Entity{}, err
}
encoded := base64.StdEncoding.EncodeToString(att.Data)
for i := 0; i < len(encoded); i += 76 {
end := min(i+76, len(encoded))
part.Write([]byte(encoded[i:end]))
part.Write([]byte("\r\n"))
}
}
if err := mw.Close(); err != nil {
return smime.Entity{}, err
}
// A top-level Content-Transfer-Encoding is redundant on a multipart container by
// MIME rules (RFC 2045 permits only 7bit/8bit/binary there anyway) but DKIM's
// FixedHeaders list always includes it in the signed header set — omitting it
// would leave that entry signing an absent header instead of a real one.
return smime.Entity{
Headers: []string{`Content-Type: multipart/mixed; boundary="` + mw.Boundary() + `"`, "Content-Transfer-Encoding: 7bit"},
Body: bodyBuf.Bytes(),
}, nil
}
// assembleMessage concatenates envelope headers and a MIME entity (its own headers,
// then body) into a flat raw RFC822 message.
func assembleMessage(envelopeHeaders []string, entity smime.Entity) string {
var b strings.Builder
for _, h := range envelopeHeaders {
b.WriteString(h)
b.WriteString("\r\n")
}
for _, h := range entity.Headers {
b.WriteString(h)
b.WriteString("\r\n")
}
b.WriteString("\r\n")
b.Write(entity.Body)
return b.String()
}
// webmailComposeSend builds, signs, and delivers a composed message: local
// recipients go straight into their mailbox (through their own filter rules), the
// rest go out via the same direct-to-MX relay used for SMTP-received mail. A copy is
// always saved to the sender's own Sent folder, and the send is logged the same way
// an SMTP-relayed message is, so it shows up in the admin's email log too.
func (a *App) webmailComposeSend(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
// ErrNotMultipart is expected (and harmless) whenever the browser submits the
// compose form without picking any attachment file — ParseMultipartForm still
// calls ParseForm internally in that case, so every other field is available;
// only a genuine parse/size failure should abort the send. This one failure mode
// redirects (rather than redisplaying, like every other failure below) since a
// parse failure means the posted fields can't be trusted to recover from.
if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
from := strings.TrimSpace(r.FormValue("from"))
if from == "" {
from = mbox.Email
}
toAddrs, errTo := parseComposeAddrs(r.FormValue("to"))
ccAddrs, errCc := parseComposeAddrs(r.FormValue("cc"))
bccAddrs, errBcc := parseComposeAddrs(r.FormValue("bcc"))
subject := strings.TrimSpace(r.FormValue("subject"))
// body_html is the Quill editor's content, sanitized the same way an incoming
// message's HTML body is before display — cheap hygiene even though it's the
// user's own browser-originated content. plainText is derived from it (not the
// raw field) so the plain-text fallback part reflects exactly what actually got
// sent, and so it can't itself carry anything sanitization would have stripped.
htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))
// fail redisplays the compose form in place with everything already typed still
// filled in (a failed send used to wipe the form via a redirect to a blank GET —
// attachments can't be restored into a native file input by a server response,
// browsers don't allow it, so that's the one thing the user is asked to redo).
fail := func(msg string) {
if r.MultipartForm != nil && len(r.MultipartForm.File["attachments"]) > 0 {
msg += " (attachments will need to be re-picked — browsers don't allow restoring them automatically)"
}
data := a.composeFormData(mbox)
data["flashes"] = append(popFlashes(w, r), Flash{Category: "error", Message: msg})
data["to"] = r.FormValue("to")
data["cc"] = r.FormValue("cc")
data["bcc"] = r.FormValue("bcc")
data["subject"] = subject
data["body_html"] = template.HTML(htmlBody)
data["in_reply_to"] = inReplyTo
data["draft_id"] = r.FormValue("draft_id")
a.render(w, r, "webmail_compose.html", data)
}
if !strings.EqualFold(from, mbox.Email) {
if canSendAs, err := a.DB.MailboxCanSendAs(mbox.ID, from); err != nil || !canSendAs {
fail("You're not authorized to send as " + from)
return
}
}
if errTo != nil || errCc != nil || errBcc != nil {
fail("One or more recipient addresses is invalid")
return
}
if len(toAddrs) == 0 {
fail("At least one recipient is required")
return
}
var attachments []composeAttachment
if r.MultipartForm != nil {
for _, fh := range r.MultipartForm.File["attachments"] {
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
// Mirrors the client-side check in webmail_compose.html — enforced again here
// since a blank send (e.g. via an accidental Enter-key form submit) must never
// succeed even with JS disabled or bypassed.
if subject == "" {
fail("Please add a subject before sending")
return
}
if plainText == "" && len(attachments) == 0 {
fail("Please write a message or add an attachment before sending")
return
}
heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
messageID := toolbox.GenerateMessageID(heloHostname)
entity, err := buildMessageEntity(plainText, htmlBody, attachments)
if err != nil {
a.Logger.Error("build outbound webmail message: %v", err)
fail("Could not build the message: " + err.Error())
return
}
wantSign := r.FormValue("smime_sign") != ""
wantEncrypt := r.FormValue("pgp_encrypt") != ""
if wantSign {
identities, err := a.DB.ListSMIMEIdentities(mbox.ID)
if err != nil || len(identities) == 0 {
fail("Signing requires your own S/MIME certificate — set one up on the Certs page first")
return
}
chosen := identities[0] // most recently created, unless the form named a specific one
if idStr := r.FormValue("smime_identity_id"); idStr != "" {
wanted := int64(atoi(idStr))
for _, id := range identities {
if id.ID == wanted {
chosen = id
break
}
}
}
cert, err := smime.ParseCertPEM([]byte(chosen.CertPEM))
if err != nil {
a.Logger.Error("parse own smime cert for mailbox %d: %v", mbox.ID, err)
fail("Your S/MIME certificate is corrupted")
return
}
key, err := smime.ParseKeyPEM([]byte(chosen.KeyPEM))
if err != nil {
a.Logger.Error("parse own smime key for mailbox %d: %v", mbox.ID, err)
fail("Your S/MIME certificate is corrupted")
return
}
if entity, err = smime.Sign(entity, cert, key); err != nil {
a.Logger.Error("smime sign for mailbox %d: %v", mbox.ID, err)
fail("Could not sign the message: " + err.Error())
return
}
}
if wantEncrypt {
// PGP handles encryption in this codebase (S/MIME, above, handles signing) —
// encrypting only ever needs public keys, so unlike signing this never
// prompts for a passphrase at compose time.
pgpIdentities, err := a.DB.ListPGPIdentities(mbox.ID)
if err != nil || len(pgpIdentities) == 0 {
fail("Encrypting requires your own PGP key (so your Sent copy stays readable) — set one up on the Certs page first")
return
}
ownPub, err := pgp.ParsePublicKey([]byte(pgpIdentities[0].PublicKeyArmor))
if err != nil {
a.Logger.Error("parse own pgp public key for mailbox %d: %v", mbox.ID, err)
fail("Your PGP key is corrupted")
return
}
recipKeys := []*openpgp.Entity{ownPub} // include the sender's own key so the Sent copy stays readable
// Recipients are chosen explicitly by picking contacts from the dropdown, not
// by matching a To/Cc/Bcc address against a contact's stored email — a
// recipient's PGP key can be filed under any email, and this way there's no
// silent "no key for this exact address" failure.
pickedIDs := r.Form["pgp_recipient_id"]
if len(pickedIDs) == 0 {
fail("Select at least one PGP recipient key to encrypt to")
return
}
var missing []string
for _, idStr := range pickedIDs {
contact, err := a.DB.GetPGPContactByID(mbox.ID, int64(atoi(idStr)))
if err != nil || contact == nil {
missing = append(missing, idStr)
continue
}
rcptKey, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
if err != nil {
missing = append(missing, contact.Email)
continue
}
recipKeys = append(recipKeys, rcptKey)
}
if len(missing) > 0 {
fail("Could not use the selected PGP key(s) for: " + strings.Join(missing, ", "))
return
}
// pgp.Entity and smime.Entity are deliberately identical structs (see both
// packages' doc comments) so this conversion is just a type-name formality,
// not a data transformation.
pgpEntity, err := pgp.EncryptEntity(pgp.Entity(entity), recipKeys)
if err != nil {
a.Logger.Error("pgp encrypt for mailbox %d: %v", mbox.ID, err)
fail("Could not encrypt the message: " + err.Error())
return
}
entity = smime.Entity(pgpEntity)
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
signed := raw
dkimSigned := false
if senderDomain := domainOfAddress(from); senderDomain != "" {
s := a.DKIM.Sign(raw, senderDomain)
dkimSigned = s != raw
signed = s
}
allRcpts := append(append(append([]string{}, toAddrs...), ccAddrs...), bccAddrs...)
types := make([]string, 0, len(allRcpts))
for range toAddrs {
types = append(types, "to")
}
for range ccAddrs {
types = append(types, "cc")
}
for range bccAddrs {
types = append(types, "bcc")
}
var localRcpts, localTypes, relayRcpts, relayTypes []string
for i, rcpt := range allRcpts {
if lm, err := a.Mailstore.ResolveRecipient(rcpt); err == nil && lm != nil {
localRcpts = append(localRcpts, rcpt)
localTypes = append(localTypes, types[i])
} else {
relayRcpts = append(relayRcpts, rcpt)
relayTypes = append(relayTypes, types[i])
}
}
var results []relay.Result
if len(relayRcpts) > 0 {
results = a.Relay.RelayEmailAsync(from, relayRcpts, signed, relayTypes)
}
for i, rcpt := range localRcpts {
results = append(results, a.deliverWebmailComposeLocally(rcpt, localTypes[i], from, subject, signed, messageID))
}
if _, err := a.Mailstore.StoreMessage(mbox.ID, "Sent", []byte(signed), messageID, from, subject); err != nil {
a.Logger.Error("store sent copy for mailbox %d: %v", mbox.ID, err)
}
// Sending a draft removes it from Drafts, same as any real mail client.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
if err := a.Mailstore.DeleteMessage(mbox.ID, int64(atoi(draftIDStr))); err != nil {
a.Logger.Error("delete sent draft %s for mailbox %d: %v", draftIDStr, mbox.ID, err)
}
}
loggedBody := plainText
if wantEncrypt {
// The whole point of checking "Encrypt" is that nobody but the recipient (and
// the sender's own Sent copy) can read it — logging the plaintext into the
// admin-visible email log would defeat that even though the wire content is
// genuinely encrypted.
loggedBody = "[PGP encrypted — plaintext not logged]"
}
if _, err := a.Relay.LogEmail(a.Cfg, a.requestIP(r), from, strings.Join(toAddrs, ", "), strings.Join(ccAddrs, ", "), strings.Join(bccAddrs, ", "),
subject, "", loggedBody, messageID, mbox.Email, dkimSigned, results); err != nil {
a.Logger.Error("log webmail send: %v", err)
}
allSucceeded := len(results) > 0
var failures []string
for _, res := range results {
if res.Status != "success" {
allSucceeded = false
reason := res.ErrorMessage
if reason == "" {
reason = res.ServerResponse
}
failures = append(failures, res.Recipient+": "+reason)
}
}
if allSucceeded {
setFlash(w, "success", "Message sent")
} else {
setFlash(w, "error", "Sent, but delivery failed — "+strings.Join(failures, "; "))
}
http.Redirect(w, r, MailboxPrefix+"/mail/Sent", http.StatusFound)
}
// webmailComposeSaveDraft stores the current compose form into the Drafts folder
// without sending it — deliberately skips the recipient/subject/body validation
// webmailComposeSend enforces (a draft can be incomplete by definition) and never
// signs or encrypts (a draft isn't going anywhere yet, so there's nothing to sign or
// encrypt to). Re-saving an already-open draft (draft_id set) replaces the old copy
// rather than accumulating duplicates.
func (a *App) webmailComposeSaveDraft(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(maxComposeUploadBytes); err != nil && err != http.ErrNotMultipart {
setFlash(w, "error", "Message (with attachments) is too large, or the form data was invalid")
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
from := strings.TrimSpace(r.FormValue("from"))
if from == "" {
from = mbox.Email
}
toAddrs, _ := parseComposeAddrs(r.FormValue("to"))
ccAddrs, _ := parseComposeAddrs(r.FormValue("cc"))
subject := strings.TrimSpace(r.FormValue("subject"))
htmlBody := htmlBodyPolicy.Sanitize(r.FormValue("body_html"))
plainText := strings.TrimSpace(plainTextPolicy.Sanitize(htmlBody))
inReplyTo := strings.TrimSpace(r.FormValue("in_reply_to"))
var attachments []composeAttachment
if r.MultipartForm != nil {
for _, fh := range r.MultipartForm.File["attachments"] {
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
attachments = append(attachments, composeAttachment{Filename: fh.Filename, ContentType: fh.Header.Get("Content-Type"), Data: data})
}
}
heloHostname := a.Cfg.Section("Server").Key("helo_hostname").String()
if heloHostname == "" {
heloHostname = a.Cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
}
messageID := toolbox.GenerateMessageID(heloHostname)
entity, err := buildMessageEntity(plainText, htmlBody, attachments)
if err != nil {
a.Logger.Error("build draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
raw := assembleMessage(buildEnvelopeHeaders(from, toAddrs, ccAddrs, subject, messageID, inReplyTo), entity)
newUID, err := a.Mailstore.StoreMessage(mbox.ID, "Drafts", []byte(raw), messageID, from, subject)
if err != nil {
a.Logger.Error("save draft for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save the draft: "+err.Error())
http.Redirect(w, r, MailboxPrefix+"/mail/compose", http.StatusFound)
return
}
// Replace, don't accumulate: re-saving an open draft deletes the previous copy.
if draftIDStr := r.FormValue("draft_id"); draftIDStr != "" {
if oldUID := int64(atoi(draftIDStr)); oldUID != newUID {
if err := a.Mailstore.DeleteMessage(mbox.ID, oldUID); err != nil {
a.Logger.Error("delete superseded draft %d for mailbox %d: %v", oldUID, mbox.ID, err)
}
}
}
setFlash(w, "success", "Draft saved")
http.Redirect(w, r, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(newUID, 10)+"&folder=Drafts", http.StatusFound)
}
// deliverWebmailComposeLocally stores a composed message into another local
// mailbox's own folder per their filter rules — mirrors smtpserver's deliverLocally,
// minus spam/DKIM-verification scoring, which is for untrusted inbound mail; a
// message an authenticated webmail user just composed doesn't need to be
// heuristically judged as spam against itself.
func (a *App) deliverWebmailComposeLocally(rcpt, rcptType, from, subject, signed, messageID string) relay.Result {
lm, err := a.Mailstore.ResolveRecipient(rcpt)
if err != nil || lm == nil {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: "recipient not found"}
}
folder := "INBOX"
markRead := false
if action, err := a.Mailstore.ApplyRules(lm.ID, map[string]string{"from": from, "to": rcpt, "subject": subject}); err == nil {
if action.Drop {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success", ServerResponse: "Discarded by recipient's filter rule"}
}
if action.Folder != "" {
folder = action.Folder
}
markRead = action.MarkRead
}
uid, err := a.Mailstore.StoreMessage(lm.ID, folder, []byte(signed), messageID, from, subject)
if err != nil {
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "failed", ErrorMessage: err.Error()}
}
if markRead {
if err := a.DB.SetMessageFlags(lm.ID, uid, `\Seen`); err != nil {
a.Logger.Error("mark_read rule failed for message %d: %v", uid, err)
}
}
return relay.Result{Recipient: rcpt, RecipientType: rcptType, Status: "success"}
}
// parseComposeAddrs parses a comma-separated address list, dropping any display name
// — every downstream consumer (ResolveRecipient, MailboxCanSendAs, RelayEmailAsync)
// expects bare addresses. Empty input is not an error (an empty Cc/Bcc is normal).
func parseComposeAddrs(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
addrs, err := mail.ParseAddressList(raw)
if err != nil {
return nil, err
}
out := make([]string, len(addrs))
for i, a := range addrs {
out[i] = a.Address
}
return out, nil
}
+98
View File
@@ -0,0 +1,98 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailSaveDraftThenSend confirms saving a draft stores it in Drafts without
// sending, reopening it via the Drafts row prefills the compose form, re-saving
// replaces the old copy (no duplicates), and finally sending it delivers the message
// and removes it from Drafts.
func TestWebmailSaveDraftThenSend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "draft-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "draft-recip@example.com", domainID, "recip-password-1!")
cookie := webmailLoginSession(t, app, senderID)
// Save a draft with no recipient at all — a draft can be incomplete.
saveForm := url.Values{"subject": {"WIP"}, "body_html": {"not done yet"}}
saveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/save-draft", strings.NewReader(saveForm.Encode()))
saveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
saveReq.AddCookie(cookie)
saveRec := httptest.NewRecorder()
mux.ServeHTTP(saveRec, saveReq)
if saveRec.Code != http.StatusFound {
t.Fatalf("save draft: status=%d body=%s", saveRec.Code, saveRec.Body.String())
}
drafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected 1 draft, got %d (err=%v)", len(drafts), err)
}
draftID := drafts[0].ID
// Reopening it via the Drafts prefill route shows the saved content.
openReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/compose?draft="+strconv.FormatInt(draftID, 10)+"&folder=Drafts", nil)
openReq.AddCookie(cookie)
openRec := httptest.NewRecorder()
mux.ServeHTTP(openRec, openReq)
if openRec.Code != http.StatusOK {
t.Fatalf("open draft: status=%d", openRec.Code)
}
body := openRec.Body.String()
if !strings.Contains(body, "WIP") || !strings.Contains(body, "not done yet") {
t.Fatalf("expected the draft's subject/body prefilled, got body: %s", body)
}
if !strings.Contains(body, `value="`+strconv.FormatInt(draftID, 10)+`"`) {
t.Fatalf("expected draft_id round-tripped into the form, got body: %s", body)
}
// Re-save with the recipient now filled in and draft_id carried — should replace,
// not duplicate.
resaveForm := url.Values{
"to": {"draft-recip@example.com"}, "subject": {"WIP"}, "body_html": {"still not done"},
"draft_id": {strconv.FormatInt(draftID, 10)},
}
resaveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/save-draft", strings.NewReader(resaveForm.Encode()))
resaveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resaveReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), resaveReq)
drafts, err = app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected still 1 draft after re-save, got %d (err=%v)", len(drafts), err)
}
newDraftID := drafts[0].ID
// Finally send it — the message is delivered and the draft is gone.
sendForm := url.Values{
"to": {"draft-recip@example.com"}, "subject": {"WIP"}, "body_html": {"finally done"},
"draft_id": {strconv.FormatInt(newDraftID, 10)},
}
sendReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(sendForm.Encode()))
sendReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
sendReq.AddCookie(cookie)
sendRec := httptest.NewRecorder()
mux.ServeHTTP(sendRec, sendReq)
if sendRec.Code != http.StatusFound {
t.Fatalf("send: status=%d body=%s", sendRec.Code, sendRec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message delivered, got %d (err=%v)", len(msgs), err)
}
remainingDrafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(remainingDrafts) != 0 {
t.Fatalf("expected the draft gone after sending, got %d (err=%v)", len(remainingDrafts), err)
}
}
+128
View File
@@ -0,0 +1,128 @@
package webui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestWebmailFolderCreateShowsUpEvenWhenEmpty confirms a freshly created folder is
// listed in the sidebar before it holds any messages — the reason
// esrv_mailbox_folders exists at all (DistinctFoldersForMailbox alone can't prove a
// folder exists until something's actually stored in it).
func TestWebmailFolderCreateShowsUpEvenWhenEmpty(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer2@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/add", strings.NewReader("name=Receipts"))
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 folder: status=%d body=%s", rec.Code, rec.Body.String())
}
folders, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil || len(folders) != 1 || folders[0] != "Receipts" {
t.Fatalf("expected [Receipts] in ListMailboxFolders, got %v (err=%v)", folders, err)
}
inboxReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
inboxReq.AddCookie(cookie)
inboxRec := httptest.NewRecorder()
mux.ServeHTTP(inboxRec, inboxReq)
if !strings.Contains(inboxRec.Body.String(), `data-folder="Receipts"`) {
t.Error("expected the empty new folder to appear in the sidebar")
}
}
// TestWebmailFolderCreateRejectsStandardAndDuplicateNames confirms you can't create a
// folder that collides with a standard folder or an existing custom one.
func TestWebmailFolderCreateRejectsStandardAndDuplicateNames(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer3@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
create := func(name string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/add", strings.NewReader("name="+name))
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("create %s: status=%d", name, rec.Code)
}
}
create("Sent") // standard folder name — should be rejected, not duplicated
create("Work")
create("Work") // duplicate — should be rejected, not duplicated
folders, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil {
t.Fatal(err)
}
if len(folders) != 1 || folders[0] != "Work" {
t.Fatalf("expected exactly [Work] (Sent rejected as standard, duplicate Work rejected), got %v", folders)
}
}
// TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard confirms
// deleting a custom folder relocates its messages to INBOX, and that a standard
// folder can't be deleted via the same route even if requested directly.
func TestWebmailFolderDeleteMovesMessagesToInboxAndCannotDeleteStandard(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "organizer4@example.com", domains[0].ID, "organizer-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
if err := app.DB.CreateMailboxFolder(mailboxID, "Newsletters"); err != nil {
t.Fatal(err)
}
raw := "From: a@example.com\r\nTo: organizer4@example.com\r\nSubject: hi\r\n\r\nbody"
if _, err := app.Mailstore.StoreMessage(mailboxID, "Newsletters", []byte(raw), "m@example.com", "a@example.com", "hi"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/Newsletters/remove", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("delete folder: status=%d", rec.Code)
}
remaining, err := app.DB.ListMailboxFolders(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected the folder record gone, got %v (err=%v)", remaining, err)
}
inboxMsgs, err := app.DB.ListMessagesInFolder(mailboxID, "INBOX")
if err != nil || len(inboxMsgs) != 1 {
t.Fatalf("expected the message relocated to INBOX, got %d (err=%v)", len(inboxMsgs), err)
}
// Attempting to delete a standard folder must be rejected, not silently succeed.
stdReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/folders/Sent/remove", nil)
stdReq.AddCookie(cookie)
stdRec := httptest.NewRecorder()
mux.ServeHTTP(stdRec, stdReq)
if stdRec.Code != http.StatusFound {
t.Fatalf("delete standard folder: status=%d", stdRec.Code)
}
sentReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/Sent", nil)
sentReq.AddCookie(cookie)
sentRec := httptest.NewRecorder()
mux.ServeHTTP(sentRec, sentReq)
if sentRec.Code != http.StatusOK {
t.Fatalf("Sent folder should still exist and render normally, status=%d", sentRec.Code)
}
}
+16 -4
View File
@@ -42,6 +42,9 @@ func (a *App) webmailLoginForm(w http.ResponseWriter, r *http.Request) {
// 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) {
if !a.rateLimitLogin(w, r) {
return
}
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
@@ -49,6 +52,11 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "webmail_login.html", M{"error": msg, "email": email})
}
if a.accountLocked("webmail_login", email) {
fail("Too many failed attempts for this account. Try again later.")
return
}
mbox, err := a.DB.GetMailboxByEmail(email)
if err != nil {
a.Logger.Error("webmail login lookup: %v", err)
@@ -56,7 +64,7 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
return
}
if mbox == nil || !db.CheckPassword(password, mbox.PasswordHash) {
_ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), false, "Incorrect email or password")
_ = a.DB.LogAuthAttempt("webmail_login", email, a.requestIP(r), false, "Incorrect email or password")
fail("Incorrect email or password.")
return
}
@@ -74,7 +82,7 @@ func (a *App) webmailLoginSubmit(w http.ResponseWriter, r *http.Request) {
fail("Something went wrong. Try again.")
return
}
_ = a.DB.LogAuthAttempt("webmail_login", email, requestIP(r), true, "Login successful")
_ = a.DB.LogAuthAttempt("webmail_login", email, a.requestIP(r), true, "Login successful")
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
return
@@ -101,6 +109,9 @@ func (a *App) webmailMFAForm(w http.ResponseWriter, r *http.Request) {
}
func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
if !a.rateLimitLogin(w, r) {
return
}
mailboxID := pendingMailboxMFAID(r)
if mailboxID == 0 {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
@@ -115,7 +126,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
code := strings.TrimSpace(r.FormValue("code"))
if !mbox.TOTPEnabled || !totp.Validate(code, mbox.TOTPSecret) {
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), false, "Invalid MFA code")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), false, "Invalid MFA code")
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
@@ -127,7 +138,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), true, "Login successful (authenticator app)")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), true, "Login successful (authenticator app)")
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
@@ -136,6 +147,7 @@ func (a *App) webmailMFASubmit(w http.ResponseWriter, r *http.Request) {
func (a *App) webmailLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
_ = a.DB.DeleteMailboxSession(c.Value)
a.pgpKeys.clearSession(c.Value)
}
clearMailboxSessionCookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
+433
View File
@@ -0,0 +1,433 @@
package webui
import (
"html/template"
"net/http"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"mailgoserver/internal/db"
"mailgoserver/internal/mailview"
)
const webmailPageSize = 25
// htmlBodyPolicy sanitizes an HTML email body before it's ever embedded into a page
// as template.HTML — an email body is attacker-controlled content (anyone can send a
// mailbox a message), so rendering it unsanitized would be a straightforward stored
// XSS vector. UGCPolicy allows the common formatting tags/attributes a real email
// body uses while stripping <script>, event handlers, javascript: URLs, etc.
// AllowDataURIImages additionally permits img[src] as a base64 data: URI, restricted
// to actual decodable image/{gif,jpeg,png,webp} content (not a blanket data: URI
// allowance) — needed so a screenshot pasted into the Quill compose editor (which
// embeds pastes as inline base64 images) still renders once sanitized, both in the
// sender's own Sent view and the recipient's inbox.
var htmlBodyPolicy = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowDataURIImages()
return p
}()
// plainTextPolicy strips all HTML tags, leaving only text content — used to derive a
// plain-text fallback part from an HTML compose body (multipart/alternative) and to
// quote a plain-text-only original message's body when replying/forwarding.
var plainTextPolicy = bluemonday.StrictPolicy()
// standardMailFolders are always shown in the folder sidebar even when empty — the
// rest of a mailbox's folder list is whatever filter-rule move_to_folder actions (or,
// later, explicit folder creation) have actually produced messages in.
var standardMailFolders = []string{"INBOX", "Spam", "Sent", "Drafts", "Trash"}
func mergeFolders(custom []string) []string {
seen := make(map[string]bool, len(standardMailFolders)+len(custom))
out := make([]string, 0, len(standardMailFolders)+len(custom))
for _, f := range standardMailFolders {
seen[f] = true
out = append(out, f)
}
for _, f := range custom {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// isStandardFolder reports whether name is one of the built-in folders every mailbox
// always has — these can never be created, renamed, or deleted through the folder
// management UI.
func isStandardFolder(name string) bool {
for _, f := range standardMailFolders {
if f == name {
return true
}
}
return false
}
// allFoldersFor is the full folder list for a mailbox: standard folders, plus every
// folder that either holds at least one message (DistinctFoldersForMailbox) or was
// explicitly created and is still empty (ListMailboxFolders) — a folder can exist via
// either path, sometimes both.
func (a *App) allFoldersFor(mailboxID int64) ([]string, error) {
fromMessages, err := a.DB.DistinctFoldersForMailbox(mailboxID)
if err != nil {
return nil, err
}
explicit, err := a.DB.ListMailboxFolders(mailboxID)
if err != nil {
return nil, err
}
return mergeFolders(append(fromMessages, explicit...)), nil
}
// folderRow adds template-ready fields to a listed message so webmail_folder.html
// stays dumb (no string-searching Flags, no subject-comparison logic, itself).
type folderRow struct {
db.MailboxMessage
Unread bool
// GroupExtra is set on the newest row of a same-subject run: how many older
// messages are collapsed under it (0 = not part of a group). Collapsed is set on
// each of those older rows, which the template hides until the group's expand
// toggle is clicked.
GroupExtra int
Collapsed bool
}
func isUnread(flags string) bool {
for _, f := range strings.Fields(flags) {
if f == `\Seen` {
return false
}
}
return true
}
// normalizeSubjectForGrouping strips Re:/Fwd:/Fw: prefixes and case for comparison.
// This is subject-based grouping, not References/In-Reply-To thread reconstruction
// — a reply with a hand-edited subject line won't group with its original, and two
// unrelated messages that happen to share a subject long after each other in
// mailbox history won't either, since grouping only ever joins consecutive rows in
// the already-sorted page (see groupConsecutiveBySubject). That's the accepted
// tradeoff for not needing a schema change or store-time header parsing.
func normalizeSubjectForGrouping(subject string) string {
s := strings.TrimSpace(subject)
for {
lower := strings.ToLower(s)
switch {
case strings.HasPrefix(lower, "re:"):
s = strings.TrimSpace(s[3:])
case strings.HasPrefix(lower, "fwd:"):
s = strings.TrimSpace(s[4:])
case strings.HasPrefix(lower, "fw:"):
s = strings.TrimSpace(s[3:])
default:
return strings.ToLower(s)
}
}
}
// groupConsecutiveBySubject annotates rows in place (same order, same length) with
// GroupExtra/Collapsed rather than restructuring them into a nested shape — the
// template can then render it exactly like a flat row list, just hiding Collapsed
// rows by default and showing a "+N more" toggle on the row above them.
func groupConsecutiveBySubject(rows []folderRow) []folderRow {
out := make([]folderRow, len(rows))
copy(out, rows)
i := 0
for i < len(out) {
key := normalizeSubjectForGrouping(out[i].CachedSubject)
j := i + 1
for key != "" && j < len(out) && normalizeSubjectForGrouping(out[j].CachedSubject) == key {
out[j].Collapsed = true
j++
}
out[i].GroupExtra = j - i - 1
i = j
}
return out
}
// webmailMailRoot sends a bare /webmail/mail visit to the inbox — there's no
// meaningful "all folders" view.
func (a *App) webmailMailRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
// webmailFolderView lists one folder's messages, newest first, paginated — or, when
// ?q= is set, a search across that folder's (or every folder's, if active_folder is
// empty — see webmailSearch) cached subject/from/to instead.
func (a *App) webmailFolderView(w http.ResponseWriter, r *http.Request) {
a.renderFolderOrSearch(w, r, r.PathValue("folder"), strings.TrimSpace(r.URL.Query().Get("q")))
}
// renderFolderOrSearch is shared by webmailFolderView (folder browsing) and
// webmailSearch (all-folders search, webmail_search.go) — same page template, same
// pagination shape, differing only in which folder (if any) is scoped and whether a
// query narrows the result set.
func (a *App) renderFolderOrSearch(w http.ResponseWriter, r *http.Request, folder, query string) {
mbox := mailboxFromContext(r)
folders, err := a.allFoldersFor(mbox.ID)
if err != nil {
a.Logger.Error("list folders for mailbox %d: %v", mbox.ID, err)
}
unreadCounts, err := a.DB.CountUnreadByFolder(mbox.ID)
if err != nil {
a.Logger.Error("count unread for mailbox %d: %v", mbox.ID, err)
}
page := atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * webmailPageSize
var total int
var rows []db.MailboxMessage
if query != "" {
total, err = a.DB.CountSearchMessagesInFolder(mbox.ID, folder, query)
if err != nil {
a.Logger.Error("count search results for mailbox %d: %v", mbox.ID, err)
}
rows, err = a.DB.SearchMessagesInFolder(mbox.ID, folder, query, offset, webmailPageSize)
} else {
total, err = a.DB.CountMessagesInFolder(mbox.ID, folder)
if err != nil {
a.Logger.Error("count messages in %s for mailbox %d: %v", folder, mbox.ID, err)
}
rows, err = a.DB.ListMessagesInFolderPage(mbox.ID, folder, offset, webmailPageSize)
}
if err != nil {
setFlash(w, "error", "Error loading messages")
}
messages := make([]folderRow, 0, len(rows))
for _, m := range rows {
messages = append(messages, folderRow{MailboxMessage: m, Unread: isUnread(m.Flags)})
}
// Grouping a cross-folder search's results by subject would mix messages that
// happen to share a subject across unrelated folders — only group a real,
// single-folder, unfiltered listing.
if query == "" && folder != "" {
messages = groupConsecutiveBySubject(messages)
}
a.render(w, r, "webmail_folder.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"messages": messages, "page": page, "total": total,
"has_next": offset+len(rows) < total, "has_prev": page > 1,
"search_query": query, "unread_counts": unreadCounts,
"flashes": popFlashes(w, r),
})
}
// webmailMessageView decrypts, parses, and renders one message — and marks it read.
func (a *App) webmailMessageView(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
msgRow, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid)
if !ok {
return
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
a.Logger.Error("fetch message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error loading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
unwrapped, smimeStatus, pgpStatus := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
if err != nil {
a.Logger.Error("parse message %d for mailbox %d: %v", uid, mbox.ID, err)
setFlash(w, "error", "Error reading message")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
return
}
if isUnread(msgRow.Flags) {
newFlags := strings.TrimSpace(msgRow.Flags + ` \Seen`)
if err := a.DB.SetMessageFlags(mbox.ID, uid, newFlags); err != nil {
a.Logger.Error("mark message %d read: %v", uid, err)
}
}
folders, _ := a.allFoldersFor(mbox.ID)
var htmlBody template.HTML
if parsed.HTMLBody != "" {
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
}
a.render(w, r, "webmail_message.html", M{
"mailbox": mbox, "folders": folders, "active_folder": folder,
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
"flashes": popFlashes(w, r),
})
}
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
// this mailbox, or isn't in the folder the URL claims — mirrors the admin side's
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
// as authorization, always re-check server-side.
func (a *App) webmailMessageWithAccess(w http.ResponseWriter, r *http.Request, mailboxID int64, folder string, uid int64) (*db.MailboxMessage, bool) {
msg, err := a.DB.GetMessageByUID(mailboxID, uid)
if err != nil || msg == nil || msg.Folder != folder {
http.NotFound(w, r)
return nil, false
}
return msg, true
}
// webmailMessageDelete moves a message to Trash — or, if it's already in Trash,
// permanently deletes it (ciphertext, index row, and frees the quota).
func (a *App) webmailMessageDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
if folder == "Trash" {
if err := a.Mailstore.DeleteMessage(mbox.ID, uid); err != nil {
setFlash(w, "error", "Error deleting message")
} else {
setFlash(w, "success", "Message permanently deleted")
}
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
return
}
if err := a.DB.MoveMessage(mbox.ID, uid, "Trash"); err != nil {
setFlash(w, "error", "Error moving message to Trash")
} else {
setFlash(w, "success", "Message moved to Trash")
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailMessageMove reassigns a message to a different (existing or freshly named)
// folder, e.g. from the message view's "Move to..." control.
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
target := strings.TrimSpace(r.FormValue("target_folder"))
if target == "" {
setFlash(w, "error", "Choose a folder to move to")
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder+"/"+strconv.FormatInt(uid, 10), http.StatusFound)
return
}
if err := a.DB.MoveMessage(mbox.ID, uid, target); err != nil {
setFlash(w, "error", "Error moving message")
} else {
setFlash(w, "success", "Message moved to "+target)
}
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
}
// webmailAttachmentDownload re-decrypts and re-parses the whole message on every
// download — there's no separate on-disk attachment cache, and message sizes on a
// self-hosted mail server are small enough that this is simpler than building one.
func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
folder := r.PathValue("folder")
uid := int64(atoi(r.PathValue("uid")))
idx := atoi(r.PathValue("idx"))
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
return
}
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
if err != nil {
http.NotFound(w, r)
return
}
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
parsed, err := mailview.Parse(unwrapped)
if err != nil || idx < 0 || idx >= len(parsed.Attachments) {
http.NotFound(w, r)
return
}
att := parsed.Attachments[idx]
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(att.Filename, `"`, "")+`"`)
w.Header().Set("Content-Type", att.ContentType)
w.Write(att.Data)
}
const maxFolderNameLen = 60
// webmailAddFolder creates a new custom folder from the sidebar's "+ New folder"
// form. A standard folder name, an empty name, or a name that already exists is
// rejected with a flash rather than silently accepted/ignored.
func (a *App) webmailAddFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := strings.TrimSpace(r.FormValue("name"))
fail := func(msg string) {
setFlash(w, "error", msg)
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
switch {
case name == "":
fail("Folder name is required")
return
case len(name) > maxFolderNameLen:
fail("Folder name is too long")
return
case isStandardFolder(name):
fail(name + " already exists")
return
}
existing, err := a.allFoldersFor(mbox.ID)
if err != nil {
fail("Error creating folder")
return
}
for _, f := range existing {
if strings.EqualFold(f, name) {
fail("A folder named " + f + " already exists")
return
}
}
if err := a.DB.CreateMailboxFolder(mbox.ID, name); err != nil {
fail("Error creating folder")
return
}
setFlash(w, "success", "Folder "+name+" created")
http.Redirect(w, r, MailboxPrefix+"/mail/"+name, http.StatusFound)
}
// webmailDeleteFolder removes a custom folder, moving any messages still in it to
// INBOX first — a folder is never left holding mail nothing can browse to anymore.
// Standard folders (checked server-side, not just hidden client-side) can't be
// removed this way.
func (a *App) webmailDeleteFolder(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
name := r.PathValue("name")
if isStandardFolder(name) {
setFlash(w, "error", name+" is a standard folder and can't be removed")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.MoveAllMessagesInFolder(mbox.ID, name, "INBOX"); err != nil {
setFlash(w, "error", "Error removing folder")
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
return
}
if err := a.DB.DeleteMailboxFolder(mbox.ID, name); err != nil {
setFlash(w, "error", "Error removing folder")
} else {
setFlash(w, "success", "Folder "+name+" removed — any mail in it moved to INBOX")
}
http.Redirect(w, r, MailboxPrefix+"/mail/INBOX", http.StatusFound)
}
+202
View File
@@ -0,0 +1,202 @@
package webui
import (
"io"
"net/http"
"strings"
"mailgoserver/internal/pgp"
)
func (a *App) webmailPGPGenerate(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
label := strings.TrimSpace(r.FormValue("label"))
passphrase := r.FormValue("passphrase")
if len(passphrase) < 8 {
setFlash(w, "error", "Choose a passphrase of at least 8 characters — this is the only thing protecting the key, so make it a real one")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
if passphrase != r.FormValue("passphrase_confirm") {
setFlash(w, "error", "Passphrases don't match")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
pubArmor, privArmor, err := pgp.GenerateKeyPair(mbox.Email, passphrase)
if err == nil {
err = a.storePGPIdentity(mbox.ID, label, mbox.Email, pubArmor, privArmor)
}
if err != nil {
a.Logger.Error("pgp generate for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Error generating PGP key")
} else {
setFlash(w, "success", "PGP key generated")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
// storePGPIdentity parses pubArmor for its fingerprint, then stores both halves —
// privArmor is expected already passphrase-protected by the caller (pgp.GenerateKeyPair
// or pgp.ImportPrivateKey both guarantee this).
func (a *App) storePGPIdentity(mailboxID int64, label, email string, pubArmor, privArmor []byte) error {
pubEntity, err := pgp.ParsePublicKey(pubArmor)
if err != nil {
return err
}
fingerprint := pgp.Fingerprint(pubEntity)
_, err = a.DB.CreatePGPIdentity(mailboxID, label, email, fingerprint, string(pubArmor), string(privArmor))
return err
}
func (a *App) webmailPGPImport(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(1 << 20); err != nil {
setFlash(w, "error", "Error reading upload")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
label := strings.TrimSpace(r.FormValue("label"))
passphrase := r.FormValue("passphrase")
if passphrase == "" {
setFlash(w, "error", "Enter the key's passphrase — its own, if it already has one, or a new one to protect it with if it doesn't")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
file, _, err := r.FormFile("key_file")
if err != nil {
setFlash(w, "error", "Please choose an armored PGP private key file (.asc)")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
var pubArmor, privArmor []byte
pubArmor, privArmor, err = pgp.ImportPrivateKey(data, passphrase)
if err == nil {
email := mbox.Email
if entity, perr := pgp.ParsePublicKey(pubArmor); perr == nil {
if id := entity.PrimaryIdentity(); id != nil && id.UserId != nil && id.UserId.Email != "" {
email = id.UserId.Email
}
}
err = a.storePGPIdentity(mbox.ID, label, email, pubArmor, privArmor)
}
}
if err != nil {
setFlash(w, "error", "Error importing PGP key: "+err.Error())
} else {
setFlash(w, "success", "PGP key imported")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailPGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
identityID := int64(atoi(r.PathValue("identity_id")))
if err := a.DB.DeletePGPIdentity(mbox.ID, identityID); err != nil {
setFlash(w, "error", "Error removing PGP key")
} else {
setFlash(w, "success", "PGP key removed")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailPGPDownloadKey(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
identityID := int64(atoi(r.PathValue("identity_id")))
identity, err := a.DB.GetPGPIdentity(mbox.ID, identityID)
if err != nil || identity == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/pgp-keys")
w.Header().Set("Content-Disposition", `attachment; filename="`+mbox.Email+`.asc"`)
w.Write([]byte(identity.PublicKeyArmor))
}
// webmailPGPUnlock verifies a passphrase against one identity's stored private key
// and, on success, caches the unlocked entity for the rest of this login session
// (see webmail_pgp_cache.go) — the one interactive "enter your passphrase" flow in
// this codebase now, since S/MIME keys are stored plain and never need unlocking.
func (a *App) webmailPGPUnlock(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
next := r.FormValue("next")
if !strings.HasPrefix(next, MailboxPrefix+"/") {
next = MailboxPrefix + "/certs"
}
identityID := int64(atoi(r.FormValue("identity_id")))
identity, err := a.DB.GetPGPIdentity(mbox.ID, identityID)
if err != nil || identity == nil {
setFlash(w, "error", "Unknown PGP key")
http.Redirect(w, r, next, http.StatusFound)
return
}
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
if err != nil {
a.Logger.Error("parse stored pgp key for identity %d: %v", identity.ID, err)
setFlash(w, "error", "That key's stored data is corrupted")
http.Redirect(w, r, next, http.StatusFound)
return
}
if err := pgp.UnlockPrivateKey(entity, r.FormValue("passphrase")); err != nil {
setFlash(w, "error", "Wrong passphrase")
http.Redirect(w, r, next, http.StatusFound)
return
}
a.pgpKeys.put(sessionToken(r), identity.ID, entity)
setFlash(w, "success", "PGP key unlocked for this session")
http.Redirect(w, r, next, http.StatusFound)
}
func (a *App) webmailPGPAddContact(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(1 << 20); err != nil {
setFlash(w, "error", "Error reading upload")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
email := strings.TrimSpace(r.FormValue("email"))
label := strings.TrimSpace(r.FormValue("label"))
file, _, err := r.FormFile("key_file")
if email == "" || err != nil {
setFlash(w, "error", "Please provide an email and a public key file")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
defer file.Close()
data, err := io.ReadAll(file)
var fingerprint string
if err == nil {
parsed, perr := pgp.ParsePublicKey(data)
if perr != nil {
err = perr
} else {
fingerprint = pgp.Fingerprint(parsed)
}
}
if err != nil {
setFlash(w, "error", "That doesn't look like a valid PGP public key file")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
if err := a.DB.UpsertPGPContact(mbox.ID, email, label, fingerprint, string(data)); err != nil {
setFlash(w, "error", "Error saving contact key")
} else {
setFlash(w, "success", "Contact key added")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailPGPRemoveContact(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
contactID := int64(atoi(r.PathValue("contact_id")))
if err := a.DB.DeletePGPContact(mbox.ID, contactID); err != nil {
setFlash(w, "error", "Error removing contact")
} else {
setFlash(w, "success", "Contact removed")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
+64
View File
@@ -0,0 +1,64 @@
package webui
import (
"net/http"
"sync"
"github.com/ProtonMail/go-crypto/openpgp"
)
// pgpKeyCache holds unlocked PGP identities for the rest of a login session —
// mirrors smimeKeyCache (webmail_smime_cache.go), but caches the whole *openpgp.Entity
// (not just a raw private key) since decrypting a PGP message needs subkey lookup on
// the entity itself, not a bare key value. Kept as its own type rather than
// generalizing smimeKeyCache into an any-typed cache — both stay simply and
// correctly typed.
type pgpKeyCache struct {
mu sync.Mutex
byTok map[string]map[int64]*openpgp.Entity
}
func newPGPKeyCache() *pgpKeyCache {
return &pgpKeyCache{byTok: map[string]map[int64]*openpgp.Entity{}}
}
func (c *pgpKeyCache) get(token string, identityID int64) (*openpgp.Entity, bool) {
c.mu.Lock()
defer c.mu.Unlock()
keys, ok := c.byTok[token]
if !ok {
return nil, false
}
entity, ok := keys[identityID]
return entity, ok
}
func (c *pgpKeyCache) put(token string, identityID int64, entity *openpgp.Entity) {
c.mu.Lock()
defer c.mu.Unlock()
if c.byTok[token] == nil {
c.byTok[token] = map[int64]*openpgp.Entity{}
}
c.byTok[token][identityID] = entity
}
// clearSession drops every unlocked identity for one session — called on logout so
// a key never outlives the session it was unlocked in. Same ponytail-flagged
// simplification as smimeKeyCache.clearSession: memory-bounded by active sessions,
// not TTL'd against a session that expires without an explicit logout.
func (c *pgpKeyCache) clearSession(token string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.byTok, token)
}
// sessionToken reads the raw webmail session cookie value, used as the PGP key
// cache's key — separate from mailboxFromContext, which only exposes the resolved
// *db.Mailbox, not the token itself.
func sessionToken(r *http.Request) string {
c, err := r.Cookie(mailboxSessionCookieName)
if err != nil {
return ""
}
return c.Value
}
+336
View File
@@ -0,0 +1,336 @@
package webui
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/mail"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/pgp"
)
// parseRawAsPGPEntity is a test-only helper that reads a stored raw RFC822
// message's Content-Type header and body into a pgp.Entity — mirrors
// parseRawAsEntity (webmail_smime_compose_test.go) for the PGP side.
func parseRawAsPGPEntity(t *testing.T, raw []byte) pgp.Entity {
t.Helper()
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatalf("parse raw message: %v", err)
}
body, err := io.ReadAll(msg.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
entity := pgp.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body}
if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" {
entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte)
}
return entity
}
// genPGPIdentity generates and stores a passphrase-protected PGP identity for a
// mailbox directly (bypassing HTTP — the identity-management HTTP flow itself is
// covered in webmail_pgp_test.go), returning its ID.
func genPGPIdentity(t *testing.T, app *App, mailboxID int64, email, passphrase string) int64 {
t.Helper()
pubArmor, privArmor, err := pgp.GenerateKeyPair(email, passphrase)
if err != nil {
t.Fatal(err)
}
if err := app.storePGPIdentity(mailboxID, "", email, pubArmor, privArmor); err != nil {
t.Fatal(err)
}
identities, err := app.DB.ListPGPIdentities(mailboxID)
if err != nil || len(identities) == 0 {
t.Fatalf("expected the identity to be stored, err=%v", err)
}
return identities[0].ID // most recently created
}
// TestWebmailComposePGPEncryptSend confirms checking "Encrypt" alone — no PGP key
// unlocked, no passphrase submitted — still works, since encrypting only ever needs
// public keys. The message only the recipient (or the sender's own Sent copy) can
// decrypt, and plaintext never appears on the wire.
func TestWebmailComposePGPEncryptSend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip@example.com", domainID, "recip-password-1!")
senderIdentityID := genPGPIdentity(t, app, senderID, "penc-sender@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip@example.com", "recipient passphrase")
senderIdentity, err := app.DB.GetPGPIdentity(senderID, senderIdentityID)
if err != nil || senderIdentity == nil {
t.Fatal(err)
}
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip@example.com"}, "subject": {"Secret"}, "body_html": {"the launch code is 1234"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("launch code")) {
t.Fatal("plaintext leaked into the stored encrypted message")
}
entity := parseRawAsPGPEntity(t, raw)
recipParsedIdentity, err := pgp.ParsePrivateKey([]byte(recipIdentity.PrivateKeyArmor))
if err != nil {
t.Fatal(err)
}
if err := pgp.UnlockPrivateKey(recipParsedIdentity, "recipient passphrase"); err != nil {
t.Fatal(err)
}
decrypted, err := pgp.DecryptEntity(entity, recipParsedIdentity)
if err != nil {
t.Fatalf("recipient DecryptEntity: %v", err)
}
if !bytes.Contains(decrypted.Body, []byte("launch code")) {
t.Fatalf("expected plaintext after decrypt, got %q", decrypted.Body)
}
// Sender's own Sent copy must also decrypt, with the sender's own key — proving
// encrypt-only (no passphrase involved at compose time) still included the
// sender's own public key as a recipient.
sentMsgs, err := app.DB.ListMessagesInFolder(senderID, "Sent")
if err != nil || len(sentMsgs) != 1 {
t.Fatalf("expected 1 sent message, got %d (err=%v)", len(sentMsgs), err)
}
sentRaw, err := app.Mailstore.FetchMessage(senderID, sentMsgs[0].ID)
if err != nil {
t.Fatal(err)
}
senderParsedIdentity, err := pgp.ParsePrivateKey([]byte(senderIdentity.PrivateKeyArmor))
if err != nil {
t.Fatal(err)
}
if err := pgp.UnlockPrivateKey(senderParsedIdentity, "sender passphrase"); err != nil {
t.Fatal(err)
}
sentEntity := parseRawAsPGPEntity(t, sentRaw)
sentDecrypted, err := pgp.DecryptEntity(sentEntity, senderParsedIdentity)
if err != nil {
t.Fatalf("sender DecryptEntity of own Sent copy: %v", err)
}
if !bytes.Contains(sentDecrypted.Body, []byte("launch code")) {
t.Fatal("sender's own Sent copy did not decrypt to the original body")
}
}
// TestWebmailComposePGPEncryptDoesNotLogPlaintext confirms the admin-visible email
// log doesn't capture the plaintext body of a PGP-encrypted send.
func TestWebmailComposePGPEncryptDoesNotLogPlaintext(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender3@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip3@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender3@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip3@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip3@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip3@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
const secretPhrase = "nuclear launch codes are 00000000"
form := url.Values{
"to": {"penc-recip3@example.com"}, "subject": {"Top secret"}, "body_html": {secretPhrase},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
logs, err := app.DB.ListEmailLogsPage(0, 10)
if err != nil {
t.Fatal(err)
}
found := false
for _, l := range logs {
if l.Subject != "Top secret" {
continue
}
found = true
if strings.Contains(l.MessageBody, secretPhrase) {
t.Fatalf("plaintext leaked into the admin email log: %q", l.MessageBody)
}
}
if !found {
t.Fatal("expected the send to appear in the email log (just without the plaintext body)")
}
}
// TestWebmailComposePGPEncryptNoRecipientPickedFails confirms encryption is refused
// (not silently skipped) when "Encrypt" is checked but no recipient key was picked
// from the dropdown — the picker replaced address-based auto-matching, so there's no
// implicit recipient to fall back to.
func TestWebmailComposePGPEncryptNoRecipientPickedFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender2@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip2@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender2@example.com", "sender passphrase")
// No contact key added, and no pgp_recipient_id submitted either.
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip2@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
// TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails confirms a
// mailbox can't encrypt to a contact ID it doesn't own — GetPGPContactByID is scoped
// per mailbox the same way every other identity/contact lookup is.
func TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender5@example.com", domainID, "sender-password-1!")
otherMailboxID := createTestMailboxWithPassword(t, app, "penc-other5@example.com", domainID, "other-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip5@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender5@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip5@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
// The contact is filed under a different mailbox than the sender.
if err := app.DB.UpsertPGPContact(otherMailboxID, "penc-recip5@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
otherContact, err := app.DB.GetPGPContact(otherMailboxID, "penc-recip5@example.com")
if err != nil || otherContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip5@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(otherContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
// TestWebmailComposePGPEncryptWithoutOwnKeyFails confirms encrypting requires the
// sender's own PGP key too (so the Sent copy stays readable) — not just the
// recipient's.
func TestWebmailComposePGPEncryptWithoutOwnKeyFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender4@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip4@example.com", domainID, "recip-password-1!")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip4@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip4@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
// Sender has no PGP identity of their own.
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip4@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
+228
View File
@@ -0,0 +1,228 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailReadPGPEncryptedMessageRequiresUnlockThenDecrypts confirms the full
// decrypt-then-view flow: opening a PGP-encrypted message the recipient hasn't
// unlocked yet shows a passphrase prompt (not the content), and submitting the
// actual /pgp/unlock form makes the same message decrypt and render on the next
// view.
func TestWebmailReadPGPEncryptedMessageRequiresUnlockThenDecrypts(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "pread-enc-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "pread-enc-recip@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "pread-enc-sender@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "pread-enc-recip@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "pread-enc-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "pread-enc-recip@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
senderCookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"pread-enc-recip@example.com"}, "subject": {"Encrypted read test"}, "body_html": {"the vault code is 9999"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(senderCookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
messageURL := MailboxPrefix + "/mail/INBOX/" + strconv.FormatInt(msgs[0].ID, 10)
recipientCookie := webmailLoginSession(t, app, recipientID)
// First view: nothing unlocked yet — should prompt, not decrypt.
viewReq := httptest.NewRequest(http.MethodGet, messageURL, nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
}
body := viewRec.Body.String()
if !strings.Contains(body, "enter your passphrase to decrypt") {
t.Fatalf("expected a needs-unlock prompt on first view, got body: %s", body)
}
if strings.Contains(body, "vault code") {
t.Fatal("plaintext should not render before unlocking")
}
// Unlock via the real endpoint, exactly as the rendered form would submit.
unlockForm := url.Values{"identity_id": {strconv.FormatInt(recipIdentityID, 10)}, "passphrase": {"recipient passphrase"}, "next": {messageURL}}
unlockReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/unlock", strings.NewReader(unlockForm.Encode()))
unlockReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
unlockReq.AddCookie(recipientCookie)
unlockRec := httptest.NewRecorder()
mux.ServeHTTP(unlockRec, unlockReq)
if unlockRec.Code != http.StatusFound || unlockRec.Header().Get("Location") != messageURL {
t.Fatalf("unlock: status=%d location=%q", unlockRec.Code, unlockRec.Header().Get("Location"))
}
// Second view: now decrypts.
viewReq2 := httptest.NewRequest(http.MethodGet, messageURL, nil)
viewReq2.AddCookie(recipientCookie)
viewRec2 := httptest.NewRecorder()
mux.ServeHTTP(viewRec2, viewReq2)
if viewRec2.Code != http.StatusOK {
t.Fatalf("view after unlock: status=%d", viewRec2.Code)
}
body2 := viewRec2.Body.String()
if !strings.Contains(body2, "PGP encrypted &amp; decrypted") {
t.Fatalf("expected a decrypted badge after unlock, got body: %s", body2)
}
if !strings.Contains(body2, "the vault code is 9999") {
t.Fatal("expected the decrypted body rendered after unlock")
}
}
// TestWebmailReadSMIMESignAndPGPEncryptTogether confirms a message both S/MIME-signed
// and PGP-encrypted unwraps correctly on read once unlocked — the trickiest path
// through unwrapCrypto's loop, since it must peel two DIFFERENT protocols' layers in
// the right order.
func TestWebmailReadSMIMESignAndPGPEncryptTogether(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "pboth-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "pboth-recip@example.com", domainID, "recip-password-1!")
genIdentity(t, app, senderID, "pboth-sender@example.com")
genPGPIdentity(t, app, senderID, "pboth-sender@example.com", "pgp sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "pboth-recip@example.com", "pgp recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "pboth-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "pboth-recip@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
senderCookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"pboth-recip@example.com"}, "subject": {"Sign and encrypt"}, "body_html": {"both protections applied"},
"smime_sign": {"1"}, "pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(senderCookie)
sendRec := httptest.NewRecorder()
mux.ServeHTTP(sendRec, req)
if sendRec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", sendRec.Code, sendRec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
messageURL := MailboxPrefix + "/mail/INBOX/" + strconv.FormatInt(msgs[0].ID, 10)
recipientCookie := webmailLoginSession(t, app, recipientID)
unlockForm := url.Values{"identity_id": {strconv.FormatInt(recipIdentityID, 10)}, "passphrase": {"pgp recipient passphrase"}}
unlockReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/unlock", strings.NewReader(unlockForm.Encode()))
unlockReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
unlockReq.AddCookie(recipientCookie)
mux.ServeHTTP(httptest.NewRecorder(), unlockReq)
viewReq := httptest.NewRequest(http.MethodGet, messageURL, nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
}
body := viewRec.Body.String()
if !strings.Contains(body, "PGP encrypted &amp; decrypted") {
t.Fatalf("expected a decrypted PGP badge, got body: %s", body)
}
if !strings.Contains(body, "Signature verified") {
t.Fatalf("expected a verified S/MIME signature badge, got body: %s", body)
}
if !strings.Contains(body, "both protections applied") {
t.Fatal("expected the plaintext rendered after unwrapping both layers")
}
}
// TestWebmailReadPGPEncryptedMessageNoIdentityShowsUndecryptable confirms a
// recipient with no PGP key at all sees an honest "could not decrypt" badge rather
// than a needs-unlock prompt (there's nothing to unlock) or a crash.
func TestWebmailReadPGPEncryptedMessageNoIdentityShowsUndecryptable(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "pread-enc-sender2@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "pread-enc-recip2@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "pread-enc-sender2@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "pread-enc-recip2@example.com", "recipient passphrase")
recipIdentity, _ := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
app.DB.UpsertPGPContact(senderID, "pread-enc-recip2@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor)
recipContact, err := app.DB.GetPGPContact(senderID, "pread-enc-recip2@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
senderCookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"pread-enc-recip2@example.com"}, "subject": {"x"}, "body_html": {"secret"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(senderCookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
msgs, _ := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
// Recipient loses their PGP key (e.g. removed it) before reading.
if err := app.DB.DeletePGPIdentity(recipientID, recipIdentityID); err != nil {
t.Fatal(err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
}
body := viewRec.Body.String()
if !strings.Contains(body, "could not decrypt") {
t.Fatalf("expected an undecryptable badge, got body: %s", body)
}
if strings.Contains(body, "secret") {
t.Fatal("plaintext should not have leaked without successful decryption")
}
}
+243
View File
@@ -0,0 +1,243 @@
package webui
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/pgp"
)
// TestWebmailPGPGenerateAndDownload confirms a mailbox owner can generate a
// passphrase-protected PGP key, see it reflected on the Certs page, and download
// the public key.
func TestWebmailPGPGenerateAndDownload(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "pgp1@example.com", domains[0].ID, "pgp-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{"label": {"Work key"}, "passphrase": {"correct horse battery staple"}, "passphrase_confirm": {"correct horse battery staple"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/identity/generate", 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("generate: status=%d body=%s", rec.Code, rec.Body.String())
}
identities, err := app.DB.ListPGPIdentities(mailboxID)
if err != nil || len(identities) != 1 {
t.Fatalf("expected 1 PGP identity, got %d (err=%v)", len(identities), err)
}
identity := identities[0]
if identity.Label != "Work key" {
t.Errorf("label = %q, want %q", identity.Label, "Work key")
}
if identity.Fingerprint == "" {
t.Error("expected a non-empty fingerprint")
}
if bytes.Contains([]byte(identity.PrivateKeyArmor), []byte("correct horse")) {
t.Fatal("stored private key armor should not contain the plaintext passphrase")
}
pageReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/certs", nil)
pageReq.AddCookie(cookie)
pageRec := httptest.NewRecorder()
mux.ServeHTTP(pageRec, pageReq)
if pageRec.Code != http.StatusOK || !strings.Contains(pageRec.Body.String(), "Work key") {
t.Fatalf("expected the Certs page to show the PGP identity, status=%d body=%s", pageRec.Code, pageRec.Body.String())
}
if !strings.Contains(pageRec.Body.String(), "Locked") {
t.Fatal("expected the PGP identity to show as locked before any unlock")
}
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/pgp/identity/"+strconv.FormatInt(identity.ID, 10)+"/download", nil)
dlReq.AddCookie(cookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK || dlRec.Body.String() != identity.PublicKeyArmor {
t.Fatalf("expected downloaded key to match stored public key, status=%d", dlRec.Code)
}
}
// TestWebmailPGPUnlockWrongPassphraseRejected confirms the unlock endpoint rejects
// an incorrect passphrase and caches nothing.
func TestWebmailPGPUnlockWrongPassphraseRejected(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "pgp2@example.com", domains[0].ID, "pgp-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{"passphrase": {"the real passphrase"}, "passphrase_confirm": {"the real passphrase"}}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/identity/generate", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
identities, _ := app.DB.ListPGPIdentities(mailboxID)
if len(identities) != 1 {
t.Fatalf("expected 1 identity, got %d", len(identities))
}
unlock := func(passphrase string) *httptest.ResponseRecorder {
f := url.Values{"identity_id": {strconv.FormatInt(identities[0].ID, 10)}, "passphrase": {passphrase}}
r := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/unlock", strings.NewReader(f.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, r)
return rec
}
if rec := unlock("wrong passphrase"); rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
if _, ok := app.pgpKeys.get(sessionTokenFromCookie(cookie), identities[0].ID); ok {
t.Fatal("expected no key cached after a wrong-passphrase unlock attempt")
}
if rec := unlock("the real passphrase"); rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
if _, ok := app.pgpKeys.get(sessionTokenFromCookie(cookie), identities[0].ID); !ok {
t.Fatal("expected the key cached after the correct passphrase")
}
}
// TestWebmailPGPContactAddAndRemove confirms a contact's public key can be added
// (validated as a real key), listed, and removed again.
func TestWebmailPGPContactAddAndRemove(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "pgp3@example.com", domains[0].ID, "pgp-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
contactPub, _, err := pgp.GenerateKeyPair("contact@other.example", "contact passphrase")
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("email", "contact@other.example")
mw.WriteField("label", "Other")
fw, err := mw.CreateFormFile("key_file", "contact.asc")
if err != nil {
t.Fatal(err)
}
fw.Write(contactPub)
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/contacts/add", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("add contact: status=%d body=%s", rec.Code, rec.Body.String())
}
contacts, err := app.DB.ListPGPContacts(mailboxID)
if err != nil || len(contacts) != 1 || contacts[0].Email != "contact@other.example" || contacts[0].Fingerprint == "" {
t.Fatalf("expected 1 contact with a fingerprint, got %+v (err=%v)", contacts, err)
}
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil)
rmReq.AddCookie(cookie)
rmRec := httptest.NewRecorder()
mux.ServeHTTP(rmRec, rmReq)
if rmRec.Code != http.StatusFound {
t.Fatalf("remove contact: status=%d", rmRec.Code)
}
remaining, err := app.DB.ListPGPContacts(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected no contacts left, got %d (err=%v)", len(remaining), err)
}
}
// TestWebmailPGPAddContactRejectsGarbage confirms an upload that isn't a valid PGP
// public key is rejected rather than silently stored.
func TestWebmailPGPAddContactRejectsGarbage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "pgp4@example.com", domains[0].ID, "pgp-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("email", "nope@example.com")
fw, err := mw.CreateFormFile("key_file", "notakey.asc")
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("this is not a pgp key"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/contacts/add", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
contacts, err := app.DB.ListPGPContacts(mailboxID)
if err != nil || len(contacts) != 0 {
t.Fatalf("expected the invalid key rejected, got %d contacts (err=%v)", len(contacts), err)
}
}
// TestWebmailPGPImportAlreadyEncryptedKey confirms importing an existing
// passphrase-protected armored key (as if exported from GnuPG) works end to end
// through the real HTTP import endpoint.
func TestWebmailPGPImportAlreadyEncryptedKey(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "pgp5@example.com", domains[0].ID, "pgp-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
_, privArmor, err := pgp.GenerateKeyPair("imported@example.com", "existing passphrase")
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("label", "Imported")
mw.WriteField("passphrase", "existing passphrase")
fw, err := mw.CreateFormFile("key_file", "imported.asc")
if err != nil {
t.Fatal(err)
}
fw.Write(privArmor)
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/identity/import", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("import: status=%d body=%s", rec.Code, rec.Body.String())
}
identities, err := app.DB.ListPGPIdentities(mailboxID)
if err != nil || len(identities) != 1 {
t.Fatalf("expected 1 identity, got %d (err=%v)", len(identities), err)
}
if identities[0].Email != "imported@example.com" {
t.Errorf("expected the email extracted from the key itself, got %q", identities[0].Email)
}
}
+58
View File
@@ -0,0 +1,58 @@
package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestWebmailRecipientSuggest confirms the autocomplete endpoint returns addresses
// this mailbox has actually exchanged mail with (Sent "To" + INBOX "From"), matching
// the query fragment, and nothing for an empty query.
func TestWebmailRecipientSuggest(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "autocomplete@example.com", domains[0].ID, "autocomplete-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
sentRaw := "From: autocomplete@example.com\r\nTo: alice@example.com\r\nSubject: hi\r\n\r\nbody"
if _, err := app.Mailstore.StoreMessage(mailboxID, "Sent", []byte(sentRaw), "s1@example.com", "autocomplete@example.com", "hi"); err != nil {
t.Fatal(err)
}
inboxRaw := "From: bob@example.com\r\nTo: autocomplete@example.com\r\nSubject: hey\r\n\r\nbody"
if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte(inboxRaw), "i1@example.com", "bob@example.com", "hey"); err != nil {
t.Fatal(err)
}
get := func(q string) []string {
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/recipients?q="+q, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
var out []string
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
}
return out
}
aliceMatches := get("alice")
if len(aliceMatches) != 1 || aliceMatches[0] != "alice@example.com" {
t.Fatalf("expected alice@example.com from Sent history, got %v", aliceMatches)
}
bobMatches := get("bob")
if len(bobMatches) != 1 || bobMatches[0] != "bob@example.com" {
t.Fatalf("expected bob@example.com from INBOX history, got %v", bobMatches)
}
if none := get(""); len(none) != 0 {
t.Fatalf("expected no suggestions for an empty query, got %v", none)
}
if none := get("nobody-like-this"); len(none) != 0 {
t.Fatalf("expected no suggestions for a non-matching query, got %v", none)
}
}
+61
View File
@@ -0,0 +1,61 @@
package webui
import (
"net/http"
"strconv"
"strings"
)
// webmailRulesList is the self-service mirror of rulesList (mailbox_rules.go) — same
// underlying CRUD (ListRulesForMailbox/CreateRule/RemoveRule), just reached from the
// mailbox owner's own portal instead of an admin managing it on their behalf.
func (a *App) webmailRulesList(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
rules, err := a.DB.ListRulesForMailbox(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading rules")
}
a.render(w, r, "webmail_rules.html", M{"mailbox": mbox, "rules": rules, "flashes": popFlashes(w, r)})
}
func (a *App) webmailAddRule(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form submission")
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
return
}
priority, _ := strconv.Atoi(r.FormValue("priority"))
matchType := r.FormValue("match_type")
action := r.FormValue("action")
actionValue := strings.TrimSpace(r.FormValue("action_value"))
conditions, ok := parseRuleConditions(r)
if !ok || !validActions[action] {
setFlash(w, "error", "Please fill in a valid condition and action")
http.Redirect(w, r, MailboxPrefix+"/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, MailboxPrefix+"/rules", http.StatusFound)
return
}
if _, err := a.DB.CreateRuleMulti(mbox.ID, priority, conditions, matchType, action, actionValue); err != nil {
setFlash(w, "error", "Error creating rule")
} else {
setFlash(w, "success", "Rule added")
}
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
}
func (a *App) webmailRemoveRule(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
ruleID := int64(atoi(r.PathValue("rule_id")))
if err := a.DB.RemoveRule(ruleID, mbox.ID); err != nil {
setFlash(w, "error", "Error removing rule")
} else {
setFlash(w, "success", "Rule removed")
}
http.Redirect(w, r, MailboxPrefix+"/rules", http.StatusFound)
}
+157
View File
@@ -0,0 +1,157 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailRulesAddAndRemove confirms a mailbox owner can create a filter rule for
// their own mailbox through the self-service portal and remove it again — the same
// underlying CRUD the admin-side page already uses.
func TestWebmailRulesAddAndRemove(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "ruler@example.com", domains[0].ID, "ruler-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=newsletter&action=move_to_folder&action_value=Newsletters"
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/add", strings.NewReader(form))
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 rule: status=%d body=%s", rec.Code, rec.Body.String())
}
rules, err := app.DB.ListRulesForMailbox(mailboxID)
if err != nil || len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d (err=%v)", len(rules), err)
}
if rules[0].ConditionValue != "newsletter" || rules[0].ActionValue != "Newsletters" {
t.Errorf("unexpected rule: %+v", rules[0])
}
// It actually takes effect at delivery time (reusing mailstore.ApplyRules,
// exercised in internal/smtpserver's own tests) — here just confirm the list page
// renders it and removal works.
listReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/rules", nil)
listReq.AddCookie(cookie)
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK || !strings.Contains(listRec.Body.String(), "Newsletters") {
t.Fatalf("expected the rule listed on the rules page, status=%d", listRec.Code)
}
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(rules[0].ID, 10)+"/remove", nil)
rmReq.AddCookie(cookie)
rmRec := httptest.NewRecorder()
mux.ServeHTTP(rmRec, rmReq)
if rmRec.Code != http.StatusFound {
t.Fatalf("remove rule: status=%d", rmRec.Code)
}
remaining, err := app.DB.ListRulesForMailbox(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected no rules left, got %d (err=%v)", len(remaining), err)
}
}
// TestWebmailRulesAddMultiCondition confirms the self-service rule builder's
// parallel condition_field/op/value arrays are correctly parsed into a
// multi-condition rule with the chosen match type.
func TestWebmailRulesAddMultiCondition(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "ruler3@example.com", domains[0].ID, "ruler-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{
"priority": {"0"},
"match_type": {"any"},
"condition_field": {"from", "subject"},
"condition_op": {"contains", "contains"},
"condition_value": {"boss@work.example", "urgent"},
"action": {"mark_as_spam"},
"action_value": {""},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/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 multi-condition rule: status=%d body=%s", rec.Code, rec.Body.String())
}
rules, err := app.DB.ListRulesForMailbox(mailboxID)
if err != nil || len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d (err=%v)", len(rules), err)
}
conditions, matchType := rules[0].Conditions()
if matchType != "any" || len(conditions) != 2 {
t.Fatalf("expected 2 OR conditions, got matchType=%q conditions=%+v", matchType, conditions)
}
if rules[0].Action != "mark_as_spam" {
t.Fatalf("expected mark_as_spam action, got %q", rules[0].Action)
}
}
// TestWebmailRulesRejectsInvalidInput confirms a malformed rule submission is
// rejected rather than silently stored.
func TestWebmailRulesRejectsInvalidInput(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "ruler2@example.com", domains[0].ID, "ruler-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
// move_to_folder with no destination folder named.
form := "priority=0&condition_field=subject&condition_op=contains&condition_value=x&action=move_to_folder&action_value="
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/add", strings.NewReader(form))
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("status=%d", rec.Code)
}
rules, err := app.DB.ListRulesForMailbox(mailboxID)
if err != nil || len(rules) != 0 {
t.Fatalf("expected the invalid rule rejected, got %d rules (err=%v)", len(rules), err)
}
}
// TestWebmailRulesScopedToOwnMailbox confirms one mailbox owner can't remove another
// mailbox's rule by guessing its ID.
func TestWebmailRulesScopedToOwnMailbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "victim2@example.com", domains[0].ID, "victim-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "attacker2@example.com", domains[0].ID, "attacker-password-1!")
ruleID, err := app.DB.CreateRule(victimID, 0, "subject", "contains", "x", "delete", "")
if err != nil {
t.Fatal(err)
}
attackerCookie := webmailLoginSession(t, app, attackerID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/rules/"+strconv.FormatInt(ruleID, 10)+"/remove", nil)
req.AddCookie(attackerCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
stillThere, err := app.DB.ListRulesForMailbox(victimID)
if err != nil || len(stillThere) != 1 {
t.Fatalf("expected the victim's rule untouched, got %d (err=%v)", len(stillThere), err)
}
}
+16
View File
@@ -0,0 +1,16 @@
package webui
import (
"net/http"
"strings"
)
// webmailSearch searches across every folder in the mailbox (or one, if ?folder= is
// given) — the all-folders case webmailFolderView's own ?q= support (scoped to
// whatever folder is already in the URL path) can't reach, since that route always
// has a folder segment.
func (a *App) webmailSearch(w http.ResponseWriter, r *http.Request) {
folder := r.URL.Query().Get("folder")
query := strings.TrimSpace(r.URL.Query().Get("q"))
a.renderFolderOrSearch(w, r, folder, query)
}
+121
View File
@@ -0,0 +1,121 @@
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func storeTestMessage(t *testing.T, app *App, mailboxID int64, folder, from, subject, body string) int64 {
t.Helper()
raw := "From: " + from + "\r\nTo: recipient@example.com\r\nSubject: " + subject + "\r\n\r\n" + body
uid, err := app.Mailstore.StoreMessage(mailboxID, folder, []byte(raw), subject+"@example.com", from, subject)
if err != nil {
t.Fatal(err)
}
return uid
}
// TestWebmailSearchAcrossFolders confirms /mail/search finds messages by subject or
// sender across every folder, and that a query scoped to one folder (via
// webmailFolderView's own ?q=) only returns that folder's matches.
func TestWebmailSearchAcrossFolders(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "searcher@example.com", domains[0].ID, "searcher-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "boss@example.com", "Quarterly report", "body one")
storeTestMessage(t, app, mailboxID, "Sent", "searcher@example.com", "Re: Quarterly report", "body two")
storeTestMessage(t, app, mailboxID, "INBOX", "someone@example.com", "totally unrelated", "body three")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/search?q=quarterly", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search: status=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Quarterly report") || !strings.Contains(body, "Re: Quarterly report") {
t.Fatalf("expected both matching messages across folders, got: %s", body)
}
if strings.Contains(body, "totally unrelated") {
t.Fatal("expected the non-matching message excluded from search results")
}
// Scoped to one folder via the folder-view's own ?q= — Sent's match shouldn't appear.
scopedReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX?q=quarterly", nil)
scopedReq.AddCookie(cookie)
scopedRec := httptest.NewRecorder()
mux.ServeHTTP(scopedRec, scopedReq)
scopedBody := scopedRec.Body.String()
if !strings.Contains(scopedBody, "Quarterly report") {
t.Fatal("expected the INBOX match present when scoped to INBOX")
}
if strings.Contains(scopedBody, "Re: Quarterly report") {
t.Fatal("expected the Sent-folder match excluded when scoped to INBOX")
}
}
// TestWebmailFolderUnreadBadges confirms the sidebar shows a per-folder unread
// count, and that it drops once a message is actually read.
func TestWebmailFolderUnreadBadges(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "badges@example.com", domains[0].ID, "badges-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid := storeTestMessage(t, app, mailboxID, "INBOX", "someone@example.com", "unread me", "body")
get := func() string {
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec.Body.String()
}
if !strings.Contains(get(), `folder-unread-badge">1<`) {
t.Fatalf("expected an unread badge showing 1, got: %s", get())
}
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
viewReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), viewReq)
if strings.Contains(get(), `folder-unread-badge">1<`) {
t.Fatal("expected the unread badge gone after reading the message")
}
}
// TestWebmailFolderGroupsConsecutiveSameSubject confirms a run of messages sharing
// a normalized subject (Re:/Fwd: stripped) collapses into one expandable row, while
// an intervening different-subject message breaks the run into separate groups.
func TestWebmailFolderGroupsConsecutiveSameSubject(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "grouper@example.com", domains[0].ID, "grouper-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
storeTestMessage(t, app, mailboxID, "INBOX", "a@example.com", "Project status", "1")
storeTestMessage(t, app, mailboxID, "INBOX", "b@example.com", "Re: Project status", "2")
storeTestMessage(t, app, mailboxID, "INBOX", "c@example.com", "unrelated", "3")
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "msg-group-toggle") || !strings.Contains(body, "+1 more") {
t.Fatalf("expected the two 'Project status' messages grouped with a +1 more toggle, got: %s", body)
}
if !strings.Contains(body, "msg-row-older") {
t.Fatal("expected the older grouped row hidden by default via msg-row-older")
}
}
+38
View File
@@ -0,0 +1,38 @@
package webui
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// TestWebmailShortcutsPresentOnFolderAndMessageViews confirms the keyboard-shortcut
// script and the message-view button ids it hooks into are actually rendered, not
// just defined-but-never-invoked.
func TestWebmailShortcutsPresentOnFolderAndMessageViews(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "shortcuts@example.com", domains[0].ID, "shortcuts-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
uid := storeTestMessage(t, app, mailboxID, "INBOX", "someone@example.com", "hi", "body")
folderReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX", nil)
folderReq.AddCookie(cookie)
folderRec := httptest.NewRecorder()
mux.ServeHTTP(folderRec, folderReq)
if !strings.Contains(folderRec.Body.String(), "msg-row-selected") {
t.Fatalf("expected the shortcuts script rendered on the folder view, got: %s", folderRec.Body.String())
}
msgReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(uid, 10), nil)
msgReq.AddCookie(cookie)
msgRec := httptest.NewRecorder()
mux.ServeHTTP(msgRec, msgReq)
msgBody := msgRec.Body.String()
if !strings.Contains(msgBody, `id="replyBtn"`) || !strings.Contains(msgBody, "msg-row-selected") {
t.Fatalf("expected both the reply button id and the shortcuts script on the message view, got: %s", msgBody)
}
}
+178
View File
@@ -0,0 +1,178 @@
package webui
import (
"io"
"net/http"
"strings"
"mailgoserver/internal/smime"
)
// webmailCertsPage shows both of a mailbox owner's certificate/key types: S/MIME
// certificates (generate/import/remove/download, used for signing, stored plain)
// and PGP keys (used for encryption — see webmail_pgp.go — each protected by its
// own passphrase), plus their collected contact certificates/keys for each
// protocol.
func (a *App) webmailCertsPage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
token := sessionToken(r)
identities, err := a.DB.ListSMIMEIdentities(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading your S/MIME certificates")
}
contacts, err := a.DB.ListSMIMEContacts(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading S/MIME contact certificates")
}
pgpIdentities, err := a.DB.ListPGPIdentities(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading your PGP keys")
}
pgpContacts, err := a.DB.ListPGPContacts(mbox.ID)
if err != nil {
setFlash(w, "error", "Error loading PGP contact keys")
}
pgpUnlocked := make(map[int64]bool, len(pgpIdentities))
for _, id := range pgpIdentities {
if _, ok := a.pgpKeys.get(token, id.ID); ok {
pgpUnlocked[id.ID] = true
}
}
a.render(w, r, "webmail_certs.html", M{
"mailbox": mbox,
"identities": identities,
"contacts": contacts,
"pgp_identities": pgpIdentities,
"pgp_unlocked": pgpUnlocked,
"pgp_contacts": pgpContacts,
"flashes": popFlashes(w, r),
})
}
// storeIdentity adds a new identity with certPEM/keyPEM stored as-is — no
// passphrase wrapping (see the schema comment on esrv_mailbox_smime_identities).
func (a *App) storeIdentity(mailboxID int64, certPEM, keyPEM []byte) error {
cert, err := smime.ParseCertPEM(certPEM)
if err != nil {
return err
}
_, err = a.DB.CreateSMIMEIdentity(mailboxID, string(certPEM), string(keyPEM), cert.NotAfter)
return err
}
func (a *App) webmailSMIMEGenerate(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
certPEM, keyPEM, err := smime.GenerateSelfSigned(mbox.Email, smime.DefaultValidity)
if err == nil {
err = a.storeIdentity(mbox.ID, certPEM, keyPEM)
}
if err != nil {
a.Logger.Error("smime generate for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Error generating certificate")
} else {
setFlash(w, "success", "S/MIME certificate generated")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailSMIMEImport(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(1 << 20); err != nil {
setFlash(w, "error", "Error reading upload")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
file, _, err := r.FormFile("p12_file")
if err != nil {
setFlash(w, "error", "Please choose a .p12/.pfx file")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
var certPEM, keyPEM []byte
// p12_password unlocks the uploaded .p12 bundle itself, a one-time-use secret
// distinct from anything stored afterward — the key is kept plain from here on.
certPEM, keyPEM, err = smime.ImportPKCS12(data, r.FormValue("p12_password"))
if err == nil {
err = a.storeIdentity(mbox.ID, certPEM, keyPEM)
}
}
if err != nil {
setFlash(w, "error", "Error importing certificate: "+err.Error())
} else {
setFlash(w, "success", "S/MIME certificate imported")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailSMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
identityID := int64(atoi(r.PathValue("identity_id")))
if err := a.DB.DeleteSMIMEIdentity(mbox.ID, identityID); err != nil {
setFlash(w, "error", "Error removing identity")
} else {
setFlash(w, "success", "S/MIME identity removed")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailSMIMEDownloadCert(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
identityID := int64(atoi(r.PathValue("identity_id")))
identity, err := a.DB.GetSMIMEIdentity(mbox.ID, identityID)
if err != nil || identity == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/x-x509-user-cert")
w.Header().Set("Content-Disposition", `attachment; filename="`+mbox.Email+`.crt"`)
w.Write([]byte(identity.CertPEM))
}
func (a *App) webmailSMIMEAddContact(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseMultipartForm(1 << 20); err != nil {
setFlash(w, "error", "Error reading upload")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
email := strings.TrimSpace(r.FormValue("email"))
file, _, err := r.FormFile("cert_file")
if email == "" || err != nil {
setFlash(w, "error", "Please provide an email and a certificate file")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err == nil {
_, err = smime.ParseCertPEM(data)
}
if err != nil {
setFlash(w, "error", "That doesn't look like a valid certificate file")
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
return
}
if err := a.DB.UpsertSMIMEContact(mbox.ID, email, string(data)); err != nil {
setFlash(w, "error", "Error saving contact certificate")
} else {
setFlash(w, "success", "Contact certificate added")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
func (a *App) webmailSMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
contactID := int64(atoi(r.PathValue("contact_id")))
if err := a.DB.DeleteSMIMEContact(mbox.ID, contactID); err != nil {
setFlash(w, "error", "Error removing contact")
} else {
setFlash(w, "success", "Contact removed")
}
http.Redirect(w, r, MailboxPrefix+"/certs", http.StatusFound)
}
@@ -0,0 +1,203 @@
package webui
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/mail"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/smime"
)
// parseRawAsEntity is a test-only helper that reads a stored raw RFC822 message's
// Content-Type header and body into a smime.Entity — enough to feed into
// smime.VerifySigned/Decrypt without needing to export parseEntity from the smime
// package just for tests.
func parseRawAsEntity(t *testing.T, raw []byte) smime.Entity {
t.Helper()
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatalf("parse raw message: %v", err)
}
body, err := io.ReadAll(msg.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
entity := smime.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body}
if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" {
entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte)
}
return entity
}
// genIdentity generates and stores an S/MIME identity for a mailbox directly
// (bypassing HTTP — the identity-management HTTP flow itself is covered separately
// in webmail_smime_test.go), returning its ID.
func genIdentity(t *testing.T, app *App, mailboxID int64, email string) int64 {
t.Helper()
certPEM, keyPEM, err := smime.GenerateSelfSigned(email, smime.DefaultValidity)
if err != nil {
t.Fatal(err)
}
if err := app.storeIdentity(mailboxID, certPEM, keyPEM); err != nil {
t.Fatal(err)
}
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
if err != nil || len(identities) == 0 {
t.Fatalf("expected the identity to be stored, err=%v", err)
}
return identities[0].ID // most recently created
}
// TestWebmailComposeSignSend confirms checking "Sign" produces a message the
// recipient (or anyone) can verify as genuinely from the sender — no passphrase
// involved, since S/MIME keys are stored plain in this codebase.
func TestWebmailComposeSignSend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "signer@example.com", domainID, "signer-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "signee@example.com", domainID, "signee-password-1!")
genIdentity(t, app, senderID, "signer@example.com")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"signee@example.com"}, "subject": {"Signed"}, "body_html": {"trust me"},
"smime_sign": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
entity := parseRawAsEntity(t, raw)
inner, signer, err := smime.VerifySigned(entity)
if err != nil {
t.Fatalf("VerifySigned: %v", err)
}
if signer.EmailAddresses[0] != "signer@example.com" {
t.Fatalf("unexpected signer: %v", signer.EmailAddresses)
}
if !bytes.Contains(inner.Body, []byte("trust me")) {
t.Fatalf("expected the body preserved inside the signed entity, got %q", inner.Body)
}
}
// TestWebmailComposeSignedMessageWithAttachmentsShowsBody confirms a real body
// message text still renders on read alongside its attachments when the message is
// also S/MIME-signed — the exact combination (signed + multiple attachments + a
// typed body) that was never actually exercised by any test before this one
// (TestWebmailComposeSendMultipleAttachments checked attachments but not signing or
// the body text; the sign-only tests never included attachments).
func TestWebmailComposeSignedMessageWithAttachmentsShowsBody(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "signedattach-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "signedattach-recip@example.com", domainID, "recip-password-1!")
genIdentity(t, app, senderID, "signedattach-sender@example.com")
cookie := webmailLoginSession(t, app, senderID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("to", "signedattach-recip@example.com")
mw.WriteField("subject", "test message")
mw.WriteField("body_html", "<p>this is my real message text</p>")
mw.WriteField("smime_sign", "1")
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
fw, err := mw.CreateFormFile("attachments", name)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("contents of " + name))
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
}
body := viewRec.Body.String()
if !strings.Contains(body, "this is my real message text") {
t.Fatalf("expected the message body rendered, got: %s", body)
}
if strings.Contains(body, "empty message body") {
t.Fatal("expected the body NOT reported as empty when real text was sent")
}
for _, name := range []string{"test.csv", "notes.md", "LICENSE"} {
if !strings.Contains(body, name) {
t.Errorf("expected attachment %q listed alongside the body", name)
}
}
}
// TestWebmailComposeSignWithoutIdentityFails confirms checking "Sign" with no
// identity on file fails cleanly instead of sending unsigned.
func TestWebmailComposeSignWithoutIdentityFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "nokey@example.com", domainID, "nokey-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "recip4@example.com", domainID, "recip4-password-1!")
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"recip4@example.com"}, "subject": {"x"}, "body_html": {"x"},
"smime_sign": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", 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 && rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
+208
View File
@@ -0,0 +1,208 @@
package webui
import (
"bytes"
"crypto/x509"
"encoding/pem"
"io"
"mime"
"net/http"
"net/mail"
"strings"
"mailgoserver/internal/db"
"mailgoserver/internal/pgp"
"mailgoserver/internal/smime"
)
// smimeInfo summarizes what unwrapSMIME found, for the message view to show as
// badges. A message can be both signed and encrypted (either nesting order); the
// zero value means "plain, no S/MIME involved."
type smimeInfo struct {
Signed bool
SignatureOK bool
SignatureErr string
SignerEmail string
Encrypted bool
Decrypted bool
DecryptErr string
}
// pgpInfo summarizes what unwrapCrypto found about PGP encryption — parallel to
// smimeInfo, but PGP has no signing role in this codebase (S/MIME handles that), so
// there's no Signed/SignatureOK equivalent here.
type pgpInfo struct {
Encrypted bool
Decrypted bool
DecryptErr string
NeedsUnlock bool
Identities []db.MailboxPGPIdentity
}
// entityHeaderNames are the only headers that belong to a MIME entity (as opposed to
// the message envelope) — see smime.Entity's doc comment.
var entityHeaderNames = []string{"Content-Type", "Content-Transfer-Encoding", "Content-Disposition"}
func isEntityHeader(name string) bool {
for _, n := range entityHeaderNames {
if strings.EqualFold(n, name) {
return true
}
}
return false
}
// tryDecryptWithIdentities attempts a pkcs7-mime decrypt using every S/MIME
// identity this mailbox holds — deliberately not inspecting the CMS RecipientInfo
// to figure out which identity a message targets; trying each key against
// smime.Decrypt is cheap and simple, and a mailbox realistically holds only a
// handful of identities. No passphrase/unlock step: S/MIME keys are stored plain.
func tryDecryptWithIdentities(entity smime.Entity, identities []db.MailboxSMIMEIdentity) (smime.Entity, bool) {
for _, id := range identities {
cert, err := smime.ParseCertPEM([]byte(id.CertPEM))
if err != nil {
continue
}
key, err := smime.ParseKeyPEM([]byte(id.KeyPEM))
if err != nil {
continue
}
if inner, err := smime.Decrypt(entity, cert, key); err == nil {
return inner, true
}
}
return smime.Entity{}, false
}
// tryPGPDecryptWithUnlockedIdentities mirrors tryDecryptWithUnlockedIdentities for
// PGP: tries every identity already unlocked in this session's key cache.
func (a *App) tryPGPDecryptWithUnlockedIdentities(token string, entity pgp.Entity, identities []db.MailboxPGPIdentity) (pgp.Entity, bool) {
for _, id := range identities {
unlocked, ok := a.pgpKeys.get(token, id.ID)
if !ok {
continue
}
if inner, err := pgp.DecryptEntity(entity, unlocked); err == nil {
return inner, true
}
}
return pgp.Entity{}, false
}
// unwrapCrypto strips any S/MIME signing and/or PGP encryption layers off raw (in
// whichever order they were applied — sign-then-encrypt or encrypt-then-sign, and
// either protocol can be outermost), returning a flat RFC822 message with the
// original envelope headers and the innermost plaintext MIME entity, ready for
// mailview.Parse. A verified S/MIME signature also gets the signer's certificate
// auto-captured into the mailbox's S/MIME contacts, the same way any mail client
// would on receiving a good signature (PGP has no signing role here, so no
// equivalent auto-capture on that side). Never returns an error for "this isn't
// S/MIME or PGP" — that's the common case, and raw is returned unchanged.
func (a *App) unwrapCrypto(r *http.Request, mailboxID int64, raw []byte) ([]byte, smimeInfo, pgpInfo) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return raw, smimeInfo{}, pgpInfo{}
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return raw, smimeInfo{}, pgpInfo{}
}
entity := smime.Entity{Body: body}
for _, name := range entityHeaderNames {
if v := msg.Header.Get(name); v != "" {
entity.Headers = append(entity.Headers, name+": "+v)
}
}
var sInfo smimeInfo
var pInfo pgpInfo
var envelopeHeaders []string
for key, vals := range msg.Header {
if isEntityHeader(key) {
continue
}
for _, v := range vals {
envelopeHeaders = append(envelopeHeaders, key+": "+v)
}
}
unwrap:
for i := 0; i < 5; i++ { // cap against pathological nesting; real S/MIME/PGP never nests this deep
ct := smime.HeaderValue(entity.Headers, "Content-Type")
mediaType := strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]))
switch mediaType {
case "multipart/signed":
sInfo.Signed = true
inner, signer, verr := smime.VerifySigned(entity)
if verr != nil {
sInfo.SignatureErr = verr.Error()
} else {
sInfo.SignatureOK = true
if len(signer.EmailAddresses) > 0 {
sInfo.SignerEmail = signer.EmailAddresses[0]
a.captureSignerContact(mailboxID, sInfo.SignerEmail, signer)
}
}
entity = inner
case "application/pkcs7-mime":
sInfo.Encrypted = true
identities, ierr := a.DB.ListSMIMEIdentities(mailboxID)
if ierr != nil {
sInfo.DecryptErr = ierr.Error()
break unwrap
}
if inner, ok := tryDecryptWithIdentities(entity, identities); ok {
sInfo.Decrypted = true
entity = inner
} else {
sInfo.DecryptErr = "no S/MIME certificate on file could decrypt this message"
break unwrap
}
case "multipart/encrypted":
_, params, perr := mime.ParseMediaType(ct)
if perr != nil || !strings.EqualFold(params["protocol"], "application/pgp-encrypted") {
break unwrap
}
pInfo.Encrypted = true
pgpIdentities, ierr := a.DB.ListPGPIdentities(mailboxID)
if ierr != nil {
pInfo.DecryptErr = ierr.Error()
break unwrap
}
if inner, ok := a.tryPGPDecryptWithUnlockedIdentities(sessionToken(r), pgp.Entity(entity), pgpIdentities); ok {
pInfo.Decrypted = true
entity = smime.Entity(inner)
} else if len(pgpIdentities) == 0 {
pInfo.DecryptErr = "no PGP key to decrypt with — set one up on the Certs page"
break unwrap
} else {
pInfo.NeedsUnlock = true
pInfo.Identities = pgpIdentities
break unwrap
}
default:
break unwrap
}
}
var buf bytes.Buffer
for _, h := range envelopeHeaders {
buf.WriteString(h)
buf.WriteString("\r\n")
}
for _, h := range entity.Headers {
buf.WriteString(h)
buf.WriteString("\r\n")
}
buf.WriteString("\r\n")
buf.Write(entity.Body)
return buf.Bytes(), sInfo, pInfo
}
func (a *App) captureSignerContact(mailboxID int64, email string, cert *x509.Certificate) {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
if err := a.DB.UpsertSMIMEContact(mailboxID, email, string(certPEM)); err != nil {
a.Logger.Error("auto-capture signer cert for mailbox %d: %v", mailboxID, err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package webui
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
// TestWebmailReadSignedMessageShowsBadgeAndCapturesContact confirms opening a signed
// message shows the verified badge and auto-adds the signer's certificate as a
// contact, matching how a real mail client behaves on a good signature.
func TestWebmailReadSignedMessageShowsBadgeAndCapturesContact(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "read-signer@example.com", domainID, "signer-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "read-signee@example.com", domainID, "signee-password-1!")
genIdentity(t, app, senderID, "read-signer@example.com")
// No contact on file yet for the recipient — this is what auto-capture should fix.
if contacts, _ := app.DB.ListSMIMEContacts(recipientID); len(contacts) != 0 {
t.Fatalf("expected no contacts before reading, got %d", len(contacts))
}
senderCookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"read-signee@example.com"}, "subject": {"Signed read test"}, "body_html": {"authentic content"},
"smime_sign": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(senderCookie)
sendRec := httptest.NewRecorder()
mux.ServeHTTP(sendRec, req)
if sendRec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", sendRec.Code, sendRec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
// Reading a signed (but not encrypted) message needs no unlock at all — signature
// verification only ever needs the signer's public certificate.
recipientCookie := webmailLoginSession(t, app, recipientID)
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
viewReq.AddCookie(recipientCookie)
viewRec := httptest.NewRecorder()
mux.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
}
body := viewRec.Body.String()
if !strings.Contains(body, "Signature verified") {
t.Fatalf("expected a verified-signature badge, got body: %s", body)
}
if !strings.Contains(body, "authentic content") {
t.Fatal("expected the message body rendered after unwrapping the signature")
}
contacts, err := app.DB.ListSMIMEContacts(recipientID)
if err != nil || len(contacts) != 1 || contacts[0].Email != "read-signer@example.com" {
t.Fatalf("expected the signer auto-captured as a contact, got %+v (err=%v)", contacts, err)
}
}
+251
View File
@@ -0,0 +1,251 @@
package webui
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"mailgoserver/internal/smime"
)
// TestWebmailSMIMEGenerateAndDownload confirms a mailbox owner can generate a
// self-signed identity (no passphrase — S/MIME keys are stored plain, since S/MIME
// is sign-only here), see it reflected on the page, and download the public
// certificate.
func TestWebmailSMIMEGenerateAndDownload(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "smime1@example.com", domains[0].ID, "smime-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("generate: status=%d body=%s", rec.Code, rec.Body.String())
}
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
if err != nil || len(identities) != 1 {
t.Fatalf("expected 1 identity stored, got %d (err=%v)", len(identities), err)
}
identity := identities[0]
if !strings.Contains(identity.KeyPEM, "PRIVATE KEY") {
t.Fatal("expected the stored key to be a usable plain PEM")
}
cert, err := smime.ParseCertPEM([]byte(identity.CertPEM))
if err != nil {
t.Fatalf("stored cert doesn't parse: %v", err)
}
if len(cert.EmailAddresses) != 1 || cert.EmailAddresses[0] != "smime1@example.com" {
t.Fatalf("unexpected cert EmailAddresses: %v", cert.EmailAddresses)
}
pageReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/certs", nil)
pageReq.AddCookie(cookie)
pageRec := httptest.NewRecorder()
mux.ServeHTTP(pageRec, pageReq)
if pageRec.Code != http.StatusOK {
t.Fatalf("expected the certs page to render, status=%d body=%s", pageRec.Code, pageRec.Body.String())
}
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identity.ID, 10)+"/download", nil)
dlReq.AddCookie(cookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusOK || dlRec.Body.String() != identity.CertPEM {
t.Fatalf("expected downloaded cert to match stored cert, status=%d", dlRec.Code)
}
}
// TestWebmailSMIMEMultipleIdentities confirms a mailbox can hold more than one
// identity at once.
func TestWebmailSMIMEMultipleIdentities(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "smime-multi@example.com", domains[0].ID, "smime-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
req.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), req)
}
identities, err := app.DB.ListSMIMEIdentities(mailboxID)
if err != nil || len(identities) != 2 {
t.Fatalf("expected 2 identities held simultaneously, got %d (err=%v)", len(identities), err)
}
}
// TestWebmailSMIMERemoveIdentity confirms removal actually deletes the DB row.
func TestWebmailSMIMERemoveIdentity(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "smime2@example.com", domains[0].ID, "smime-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
genReq.AddCookie(cookie)
mux.ServeHTTP(httptest.NewRecorder(), genReq)
identities, _ := app.DB.ListSMIMEIdentities(mailboxID)
if len(identities) != 1 {
t.Fatalf("expected 1 identity, got %d", len(identities))
}
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(identities[0].ID, 10)+"/remove", nil)
rmReq.AddCookie(cookie)
rmRec := httptest.NewRecorder()
mux.ServeHTTP(rmRec, rmReq)
if rmRec.Code != http.StatusFound {
t.Fatalf("remove: status=%d", rmRec.Code)
}
remaining, err := app.DB.ListSMIMEIdentities(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected identity gone, got %d (err=%v)", len(remaining), err)
}
}
// TestWebmailSMIMEContactAddAndRemove confirms a contact certificate can be added
// (validated as a real cert), listed, and removed again.
func TestWebmailSMIMEContactAddAndRemove(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "smime3@example.com", domains[0].ID, "smime-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
contactCertPEM, _, err := smime.GenerateSelfSigned("contact@other.example", smime.DefaultValidity)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("email", "contact@other.example")
fw, err := mw.CreateFormFile("cert_file", "contact.pem")
if err != nil {
t.Fatal(err)
}
fw.Write(contactCertPEM)
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("add contact: status=%d body=%s", rec.Code, rec.Body.String())
}
contacts, err := app.DB.ListSMIMEContacts(mailboxID)
if err != nil || len(contacts) != 1 || contacts[0].Email != "contact@other.example" {
t.Fatalf("expected 1 contact, got %+v (err=%v)", contacts, err)
}
rmReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil)
rmReq.AddCookie(cookie)
rmRec := httptest.NewRecorder()
mux.ServeHTTP(rmRec, rmReq)
if rmRec.Code != http.StatusFound {
t.Fatalf("remove contact: status=%d", rmRec.Code)
}
remaining, err := app.DB.ListSMIMEContacts(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected no contacts left, got %d (err=%v)", len(remaining), err)
}
}
// TestWebmailSMIMEAddContactRejectsGarbage confirms an upload that isn't a valid
// certificate is rejected rather than silently stored.
func TestWebmailSMIMEAddContactRejectsGarbage(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "smime4@example.com", domains[0].ID, "smime-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("email", "nope@example.com")
fw, err := mw.CreateFormFile("cert_file", "notacert.pem")
if err != nil {
t.Fatal(err)
}
fw.Write([]byte("this is not a certificate"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/add", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
contacts, err := app.DB.ListSMIMEContacts(mailboxID)
if err != nil || len(contacts) != 0 {
t.Fatalf("expected the invalid cert rejected, got %d contacts (err=%v)", len(contacts), err)
}
}
// TestWebmailSMIMEScopedToOwnMailbox confirms one mailbox owner can't remove
// another's contact by guessing its ID, and can't reach another's identity.
func TestWebmailSMIMEScopedToOwnMailbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "victim3@example.com", domains[0].ID, "victim-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "attacker3@example.com", domains[0].ID, "attacker-password-1!")
if err := app.DB.UpsertSMIMEContact(victimID, "someone@example.com", "irrelevant-pem-for-this-test"); err != nil {
t.Fatal(err)
}
contacts, _ := app.DB.ListSMIMEContacts(victimID)
attackerCookie := webmailLoginSession(t, app, attackerID)
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/contacts/"+strconv.FormatInt(contacts[0].ID, 10)+"/remove", nil)
req.AddCookie(attackerCookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
stillThere, err := app.DB.ListSMIMEContacts(victimID)
if err != nil || len(stillThere) != 1 {
t.Fatalf("expected the victim's contact untouched, got %d (err=%v)", len(stillThere), err)
}
// Attacker downloading the victim's identity by guessing its ID should 404, not
// leak the victim's cert.
victimCookie := webmailLoginSession(t, app, victimID)
genReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/smime/identity/generate", nil)
genReq.AddCookie(victimCookie)
mux.ServeHTTP(httptest.NewRecorder(), genReq)
victimIdentities, _ := app.DB.ListSMIMEIdentities(victimID)
if len(victimIdentities) != 1 {
t.Fatalf("expected 1 victim identity, got %d", len(victimIdentities))
}
dlReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/smime/identity/"+strconv.FormatInt(victimIdentities[0].ID, 10)+"/download", nil)
dlReq.AddCookie(attackerCookie)
dlRec := httptest.NewRecorder()
mux.ServeHTTP(dlRec, dlReq)
if dlRec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for an attacker guessing another mailbox's identity ID, got %d", dlRec.Code)
}
}
func sessionTokenFromCookie(c *http.Cookie) string { return c.Value }
@@ -0,0 +1,186 @@
package webui
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/pgp"
)
// TestWebmailComposeFrictionFixesFullJourney is a live-HTTP smoke test (real
// httptest.NewServer + a cookie-jar client, not raw httptest.NewRecorder calls) that
// walks the exact journey a user would take through all six friction-fix milestones
// in one continuous session: generate a passwordless S/MIME cert, fail a send and
// see the real error with content preserved, save a draft, reopen and re-save it,
// pick a PGP recipient from the dropdown (not by address auto-match), and finally
// send successfully. Session cookies flow exactly as a browser would send them.
func TestWebmailComposeFrictionFixesFullJourney(t *testing.T) {
app := newTestApp(t)
srv := httptest.NewServer(app.Mux())
defer srv.Close()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "journey-sender@example.com", domainID, "sender-password-1!")
recipID := createTestMailboxWithPassword(t, app, "journey-recip@example.com", domainID, "recip-password-1!")
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // inspect redirects ourselves, like a test proxy would
},
}
post := func(path string, form url.Values) *http.Response {
t.Helper()
resp, err := client.PostForm(srv.URL+path, form)
if err != nil {
t.Fatal(err)
}
return resp
}
get := func(path string) (*http.Response, string) {
t.Helper()
resp, err := client.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(body)
}
// --- Login ---
loginResp := post(MailboxPrefix+"/login", url.Values{"email": {"journey-sender@example.com"}, "password": {"sender-password-1!"}})
loginResp.Body.Close()
if loginResp.StatusCode != http.StatusFound {
t.Fatalf("login: status=%d", loginResp.StatusCode)
}
// --- Milestone 1: generate an S/MIME cert with NO passphrase fields at all ---
genResp := post(MailboxPrefix+"/smime/identity/generate", url.Values{})
genResp.Body.Close()
if genResp.StatusCode != http.StatusFound {
t.Fatalf("smime generate: status=%d", genResp.StatusCode)
}
identities, err := app.DB.ListSMIMEIdentities(senderID)
if err != nil || len(identities) != 1 {
t.Fatalf("expected 1 S/MIME identity, got %d (err=%v)", len(identities), err)
}
// --- Milestones 2+3: a rejected send (missing subject) shows the specific
// validation error rather than a generic message, and redisplays the form with
// the already-typed body still filled in instead of wiping it via a redirect ---
failResp := post(MailboxPrefix+"/mail/compose", url.Values{
"to": {"journey-recip@example.com"}, "subject": {""}, "body_html": {"partially written thought"},
})
failBody, _ := io.ReadAll(failResp.Body)
failResp.Body.Close()
if failResp.StatusCode != http.StatusOK {
t.Fatalf("expected the failed send to redisplay the form (200), got %d", failResp.StatusCode)
}
if !strings.Contains(string(failBody), "partially written thought") {
t.Fatalf("expected the typed body preserved after a failed send, got: %s", failBody)
}
if !strings.Contains(string(failBody), "Please add a subject") {
t.Fatalf("expected the specific validation error shown, got: %s", failBody)
}
// --- Milestone 4: save a draft, reopen it, re-save it (no duplicate) ---
post(MailboxPrefix+"/mail/save-draft", url.Values{"subject": {"Draft subject"}, "body_html": {"draft body"}}).Body.Close()
drafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected 1 draft, got %d (err=%v)", len(drafts), err)
}
draftID := drafts[0].ID
_, openBody := get(MailboxPrefix + "/mail/compose?draft=" + strconv.FormatInt(draftID, 10) + "&folder=Drafts")
if !strings.Contains(openBody, "Draft subject") || !strings.Contains(openBody, "draft body") {
t.Fatalf("expected the reopened draft prefilled, got: %s", openBody)
}
post(MailboxPrefix+"/mail/save-draft", url.Values{
"subject": {"Draft subject"}, "body_html": {"draft body, edited"}, "draft_id": {strconv.FormatInt(draftID, 10)},
}).Body.Close()
drafts, err = app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(drafts) != 1 {
t.Fatalf("expected still 1 draft after re-save (no duplicate), got %d (err=%v)", len(drafts), err)
}
draftID = drafts[0].ID
// --- Milestone 5: PGP encrypt using the recipient picker (not address matching)
// — the sender needs their own PGP key too, and the recipient's key is filed as a
// contact under a DIFFERENT email than the actual To address, proving the picker
// (not auto-match-by-address) is what makes this work.
senderPub, senderPriv, err := pgp.GenerateKeyPair("journey-sender@example.com", "sender pgp pass")
if err != nil {
t.Fatal(err)
}
if err := app.storePGPIdentity(senderID, "", "journey-sender@example.com", senderPub, senderPriv); err != nil {
t.Fatal(err)
}
recipPub, _, err := pgp.GenerateKeyPair("journey-recip@example.com", "recip pgp pass")
if err != nil {
t.Fatal(err)
}
entity, err := pgp.ParsePublicKey(recipPub)
if err != nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "filed-under-a-different-address@example.com", "Journey recipient", pgp.Fingerprint(entity), string(recipPub)); err != nil {
t.Fatal(err)
}
contact, err := app.DB.GetPGPContact(senderID, "filed-under-a-different-address@example.com")
if err != nil || contact == nil {
t.Fatal(err)
}
sendResp := post(MailboxPrefix+"/mail/compose", url.Values{
"to": {"journey-recip@example.com"}, "subject": {"Final message"}, "body_html": {"the actual content"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(contact.ID, 10)},
"draft_id": {strconv.FormatInt(draftID, 10)},
})
sendBody, _ := io.ReadAll(sendResp.Body)
sendResp.Body.Close()
if sendResp.StatusCode != http.StatusFound {
t.Fatalf("expected the send to succeed, got status=%d body=%s", sendResp.StatusCode, sendBody)
}
msgs, err := app.DB.ListMessagesInFolder(recipID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message delivered, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "the actual content") {
t.Fatal("expected the body encrypted, not plaintext, in the stored message")
}
// Sending with draft_id set removes the draft, same as any real mail client.
remainingDrafts, err := app.DB.ListMessagesInFolder(senderID, "Drafts")
if err != nil || len(remainingDrafts) != 0 {
t.Fatalf("expected the draft gone after sending, got %d (err=%v)", len(remainingDrafts), err)
}
// --- Milestone 6: the Mail view renders the compose popup widget, wired to the
// real openCompose(...) calls, not the old plain navigation links. ---
_, folderBody := get(MailboxPrefix + "/mail/INBOX")
if !strings.Contains(folderBody, "openCompose('/webmail/mail/compose')") {
t.Fatalf("expected the Compose button wired to the popup widget, got: %s", folderBody)
}
if !strings.Contains(folderBody, "composePopup") {
t.Fatalf("expected the compose popup widget markup present on the mail view, got: %s", folderBody)
}
}
+14 -4
View File
@@ -56,15 +56,25 @@ func TestWebmailLoginSucceedsAndReachesDashboard(t *testing.T) {
t.Fatal("expected a mailbox session cookie to be set")
}
// The webmail root is the mailbox itself now, not account settings — it redirects
// straight to the inbox.
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 rec2.Code != http.StatusFound || rec2.Header().Get("Location") != MailboxPrefix+"/mail/INBOX" {
t.Fatalf("expected the webmail root to redirect to the inbox, got %d Location=%q", rec2.Code, rec2.Header().Get("Location"))
}
if !strings.Contains(rec2.Body.String(), "portaluser@example.com") {
t.Fatal("expected the dashboard to show the mailbox's own email")
req3 := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/account", nil)
req3.AddCookie(sessionCookie)
rec3 := httptest.NewRecorder()
mux.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("expected the account page to render, got %d: %s", rec3.Code, rec3.Body.String())
}
if !strings.Contains(rec3.Body.String(), "portaluser@example.com") {
t.Fatal("expected the account page to show the mailbox's own email")
}
}
+5 -5
View File
@@ -140,7 +140,7 @@ func (a *App) webmailPasskeyRegisterFinish(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
return
}
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "Passkey added: "+name)
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "Passkey added: "+name)
writeJSON(w, http.StatusOK, M{"success": true})
}
@@ -149,10 +149,10 @@ func (a *App) webmailPasskeyRemove(w http.ResponseWriter, r *http.Request) {
if err := a.DB.DeleteMailboxWebAuthnCredential(pathID(r), mbox.ID); err != nil {
setFlash(w, "error", "Could not remove passkey")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "Passkey removed")
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "Passkey removed")
setFlash(w, "success", "Passkey removed")
}
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailPasskeyLoginBegin starts the passkey ceremony for the mailbox that's already
@@ -218,7 +218,7 @@ func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request)
}
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
clearMailboxWebauthnSession(w)
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), false, "Passkey verification failed")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), false, "Passkey verification failed")
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
return
}
@@ -229,7 +229,7 @@ func (a *App) webmailPasskeyLoginFinish(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
return
}
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, requestIP(r), true, "Login successful (passkey)")
_ = a.DB.LogAuthAttempt("webmail_login", mbox.Email, a.requestIP(r), true, "Login successful (passkey)")
clearMailboxPendingMFACookie(w)
setMailboxSessionCookie(w, token, r.TLS != nil)
writeJSON(w, http.StatusOK, M{"success": true})
+77 -4
View File
@@ -6,6 +6,7 @@ import (
"html/template"
"io/fs"
"net/http"
"net/netip"
"time"
"gopkg.in/ini.v1"
@@ -13,6 +14,7 @@ import (
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
@@ -25,18 +27,44 @@ type App struct {
DKIM *dkim.Manager
Mailstore *mailstore.Store
ACME *acmecert.Manager
Relay *relay.Relay // used by the webmail client's compose/send (see webmail_compose.go)
Cfg *ini.File
ConfigPath string
Logger *toolbox.Logger
SMTPUp func() bool // reports whether the SMTP listeners are currently running
// pgpKeys caches unlocked PGP identities for the rest of a login session — purely
// in-memory server state, not an external dependency, so it's built internally
// rather than threaded in as a New(...) parameter. S/MIME has no equivalent cache
// since its private keys are stored plain, not passphrase-protected.
pgpKeys *pgpKeyCache
// trustedProxies gates requestIP's use of forwarded headers — see trusted_proxy.go.
trustedProxies []netip.Prefix
// loginLimiter throttles login POSTs per source IP — see ratelimit.go. Separate
// from the per-account lockout (login.go/webmail_login.go), which uses
// esrv_auth_logs via CountRecentFailedAttempts instead of in-memory state.
loginLimiter *ipRateLimiter
// appSecret signs CSRF tokens — see csrf.go and LoadOrCreateAppSecret (secret.go).
appSecret []byte
templates map[string]*template.Template
}
// 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, 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}
// appSecret is loaded by the caller via LoadOrCreateAppSecret, mirroring how
// mailstore's master key is loaded in main.go and threaded in rather than resolved
// internally (both are file paths relative to the app's root working directory,
// which this package doesn't otherwise know).
func New(database *db.DB, dkimMgr *dkim.Manager, mstore *mailstore.Store, acmeMgr *acmecert.Manager, relayer *relay.Relay, cfg *ini.File, configPath string, logger *toolbox.Logger, smtpUp func() bool, appSecret []byte) (*App, error) {
trustedProxies := parseTrustedProxies(cfg.Section("Server").Key("trusted_proxies").MustString(""), logger)
a := &App{
DB: database, DKIM: dkimMgr, Mailstore: mstore, ACME: acmeMgr, Relay: relayer, Cfg: cfg, ConfigPath: configPath, Logger: logger, SMTPUp: smtpUp,
pgpKeys: newPGPKeyCache(), trustedProxies: trustedProxies, loginLimiter: newIPRateLimiter(20, time.Minute), appSecret: appSecret,
}
if err := a.loadTemplates(); err != nil {
return nil, err
}
@@ -82,6 +110,11 @@ func (a *App) Mux() *http.ServeMux {
panic(err) // embed.go's directive is malformed if this ever fails
}
outer.Handle("GET "+Prefix+"/static/", http.StripPrefix(Prefix+"/static/", http.FileServerFS(staticFS)))
// Webmail templates reference the same vendored assets (Bootstrap/Quill/etc. —
// see static/vendor/) but live under a different URL prefix, so they need their
// own route to the identical embedded files rather than reaching across into the
// admin prefix.
outer.Handle("GET "+MailboxPrefix+"/static/", http.StripPrefix(MailboxPrefix+"/static/", http.FileServerFS(staticFS)))
outer.HandleFunc("GET "+Prefix+"/login", a.loginForm)
outer.HandleFunc("POST "+Prefix+"/login", a.loginSubmit)
@@ -102,7 +135,8 @@ func (a *App) Mux() *http.ServeMux {
outer.HandleFunc("POST "+MailboxPrefix+"/logout", a.webmailLogout)
webmailMux := http.NewServeMux()
webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailDashboard)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/", a.webmailMailRoot)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/account", a.webmailDashboard)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
@@ -113,6 +147,39 @@ func (a *App) Mux() *http.ServeMux {
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)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail", a.webmailMailRoot)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/compose", a.webmailComposeForm)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/compose", a.webmailComposeSend)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/save-draft", a.webmailComposeSaveDraft)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/search", a.webmailSearch)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/recipients", a.webmailRecipientSuggest)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}", a.webmailFolderView)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}", a.webmailMessageView)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/delete", a.webmailMessageDelete)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/rules", a.webmailRulesList)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/add", a.webmailAddRule)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/rules/{rule_id}/remove", a.webmailRemoveRule)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/certs", a.webmailCertsPage)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/generate", a.webmailSMIMEGenerate)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/import", a.webmailSMIMEImport)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/identity/{identity_id}/remove", a.webmailSMIMERemoveIdentity)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/smime/identity/{identity_id}/download", a.webmailSMIMEDownloadCert)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/contacts/add", a.webmailSMIMEAddContact)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/smime/contacts/{contact_id}/remove", a.webmailSMIMERemoveContact)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/generate", a.webmailPGPGenerate)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/import", a.webmailPGPImport)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/identity/{identity_id}/remove", a.webmailPGPRemoveIdentity)
webmailMux.HandleFunc("GET "+MailboxPrefix+"/pgp/identity/{identity_id}/download", a.webmailPGPDownloadKey)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/unlock", a.webmailPGPUnlock)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/contacts/add", a.webmailPGPAddContact)
webmailMux.HandleFunc("POST "+MailboxPrefix+"/pgp/contacts/{contact_id}/remove", a.webmailPGPRemoveContact)
outer.Handle(MailboxPrefix+"/", a.requireMailboxAuth(webmailMux))
mux := http.NewServeMux()
@@ -188,6 +255,13 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("GET "+Prefix+"/ips/{id}/edit", a.editIPForm)
mux.HandleFunc("POST "+Prefix+"/ips/{id}/edit", a.editIP)
mux.HandleFunc("GET "+Prefix+"/blacklist", a.requireGlobalAdmin(a.blacklistPage))
mux.HandleFunc("POST "+Prefix+"/blacklist/add", a.requireGlobalAdmin(a.addBlacklistEntry))
mux.HandleFunc("POST "+Prefix+"/blacklist/{id}/remove", a.requireGlobalAdmin(a.removeBlacklistEntry))
mux.HandleFunc("POST "+Prefix+"/blacklist/{id}/whitelist", a.requireGlobalAdmin(a.whitelistBlacklistedIP))
mux.HandleFunc("POST "+Prefix+"/abuse-whitelist/add", a.requireGlobalAdmin(a.addAbuseWhitelistEntry))
mux.HandleFunc("POST "+Prefix+"/abuse-whitelist/{id}/remove", a.requireGlobalAdmin(a.removeAbuseWhitelistEntry))
mux.HandleFunc("GET "+Prefix+"/dkim", a.dkimList)
mux.HandleFunc("POST "+Prefix+"/dkim/create", a.createDKIM)
mux.HandleFunc("POST "+Prefix+"/dkim/{id}/regenerate", a.regenerateDKIM)
@@ -217,7 +291,6 @@ func (a *App) Mux() *http.ServeMux {
mux.HandleFunc("GET "+Prefix+"/msg/content/{id}", a.viewMessageContent)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/download", a.downloadAttachment)
mux.HandleFunc("GET "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("POST "+Prefix+"/msg/attachment/{id}/delete", a.deleteAttachment)
mux.HandleFunc("GET "+Prefix, func(w http.ResponseWriter, r *http.Request) {
+9 -1
View File
@@ -15,6 +15,7 @@ import (
"mailgoserver/internal/db"
"mailgoserver/internal/dkim"
"mailgoserver/internal/mailstore"
"mailgoserver/internal/relay"
"mailgoserver/internal/toolbox"
)
@@ -129,7 +130,12 @@ func newTestApp(t *testing.T) *App {
cfg.SaveTo(configPath)
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 })
relayer := relay.New(database, cfg, toolbox.GetLogger("test"))
appSecret, err := LoadOrCreateAppSecret(filepath.Join(dir, "app_secret.key"))
if err != nil {
t.Fatalf("LoadOrCreateAppSecret: %v", err)
}
app, err := New(database, dkimMgr, mstore, acmeMgr, relayer, cfg, configPath, toolbox.GetLogger("test"), func() bool { return true }, appSecret)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -184,8 +190,10 @@ func TestAllPagesRender(t *testing.T) {
"/ips", "/ips/add", "/ips/" + itoa(ips[0].ID) + "/edit",
"/dkim", "/dkim/" + itoa(keys[0].ID) + "/edit",
"/logs", "/logs?type=emails", "/logs?type=auth",
"/logs?type=auth&auth_category=admin", "/logs?type=auth&auth_category=webmail", "/logs?type=auth&auth_category=mailserver",
"/settings",
"/letsencrypt",
"/blacklist",
"/msg/content/" + itoa(logs[0].ID),
"/admins", "/admins/add",
}