Chore: Remove gorilla/mux dependency and replace with stdlib routing

This commit is contained in:
Ralph Slooten
2026-05-05 16:05:23 +12:00
parent f0777c7e63
commit 3b2423bdf1
9 changed files with 86 additions and 96 deletions

1
go.mod
View File

@@ -9,7 +9,6 @@ require (
github.com/axllent/semver v1.0.0
github.com/goccy/go-yaml v1.19.2
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime/v2 v2.3.0
github.com/klauspost/compress v1.18.5

2
go.sum
View File

@@ -47,8 +47,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=

View File

@@ -9,7 +9,6 @@ import (
"net/url"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
)
// GetMessage (method: GET) returns the Message as JSON
@@ -32,9 +31,7 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -78,9 +75,7 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -133,10 +128,8 @@ func DownloadAttachment(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
id := r.PathValue("id")
partID := r.PathValue("partID")
if id == "latest" {
var err error
@@ -183,9 +176,7 @@ func DownloadRaw(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
dl := r.FormValue("dl")
if id == "latest" {

View File

@@ -11,7 +11,6 @@ import (
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime/v2"
)
@@ -35,8 +34,7 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -105,8 +103,7 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error
@@ -158,8 +155,7 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
if id == "latest" {
var err error

View File

@@ -13,7 +13,6 @@ import (
"github.com/axllent/mailpit/internal/smtpd"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
@@ -45,9 +44,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
return
}
vars := mux.Vars(r)
id := vars["id"]
id := r.PathValue("id")
msg, err := storage.GetMessageRaw(id)
if err != nil {

View File

@@ -6,7 +6,6 @@ import (
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
// GetAllTags (method: GET) will get all tags currently in use
@@ -97,9 +96,7 @@ func RenameTag(w http.ResponseWriter, r *http.Request) {
// 200: OKResponse
// 400: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
tag := r.PathValue("tag")
decoder := json.NewDecoder(r.Body)
@@ -141,9 +138,7 @@ func DeleteTag(w http.ResponseWriter, r *http.Request) {
// 200: OKResponse
// 400: ErrorResponse
vars := mux.Vars(r)
tag := vars["tag"]
tag := r.PathValue("tag")
if err := storage.DeleteTag(tag); err != nil {
httpError(w, err.Error())

View File

@@ -11,7 +11,6 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/axllent/mailpit/internal/tools"
"github.com/gorilla/mux"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
@@ -38,9 +37,8 @@ func GetMessageHTML(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
id := strings.TrimSuffix(path, ".html")
if id == "latest" {
var err error
@@ -123,9 +121,8 @@ func GetMessageText(w http.ResponseWriter, r *http.Request) {
// 400: ErrorResponse
// 404: NotFoundResponse
vars := mux.Vars(r)
id := vars["id"]
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
id := strings.TrimSuffix(path, ".txt")
if id == "latest" {
var err error

View File

@@ -12,7 +12,6 @@ import (
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime/v2"
"github.com/kovidgoyal/imaging"
)
@@ -42,10 +41,8 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
// 200: BinaryResponse
// 400: ErrorResponse
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
id := r.PathValue("id")
partID := r.PathValue("partID")
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {

View File

@@ -28,7 +28,6 @@ import (
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
"github.com/lithammer/shortuuid/v4"
)
@@ -64,37 +63,43 @@ func Listen() {
r := apiRoutes()
// kubernetes probes
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc(config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
r.HandleFunc("GET "+config.Webroot+"livez", handlers.HealthzHandler)
r.HandleFunc("GET "+config.Webroot+"readyz", handlers.ReadyzHandler(isReady))
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler))
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middleWareFunc(embedController))
r.PathPrefix(config.Webroot + "api/").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.ico").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "favicon.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "mailpit.svg").Handler(middleWareFunc(embedController))
r.Path(config.Webroot + "notification.png").Handler(middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"dist/", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"api/", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"favicon.ico", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"favicon.svg", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"mailpit.svg", middleWareFunc(embedController))
r.Handle("GET "+config.Webroot+"notification.png", middleWareFunc(embedController))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redirect := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
r.HandleFunc("GET "+redirect, middleWareFunc(addSlashToWebroot))
}
// UI shortcut
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage))
// frontend testing
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(apiv1.GetMessageHTML)).Methods("GET")
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(apiv1.GetMessageText)).Methods("GET")
// frontend testing + web UI via virtual index.html
// Go's ServeMux wildcards must span a full path segment so {id}.html is invalid;
// viewHandler dispatches on the path suffix instead.
r.HandleFunc("GET "+config.Webroot+"view/", middleWareFunc(viewHandler))
// web UI via virtual index.html
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot + "search").Handler(middleWareFunc(index)).Methods("GET")
r.Path(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
r.Handle("GET "+config.Webroot+"search", middleWareFunc(index))
// Exact-match the webroot; stdlib "/" is always a subtree so we guard inside.
r.HandleFunc("GET "+config.Webroot, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != config.Webroot {
http.NotFound(w, r)
return
}
middleWareFunc(index)(w, r)
})
if auth.UICredentials != nil {
logger.Log().Info("[http] enabling basic authentication")
@@ -165,51 +170,51 @@ func Listen() {
}
}
func apiRoutes() *mux.Router {
r := mux.NewRouter()
func apiRoutes() *http.ServeMux {
r := http.NewServeMux()
// API V1
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages))
r.HandleFunc("PUT "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages))
r.HandleFunc("GET "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch))
r.HandleFunc("POST "+config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler))
r.HandleFunc("GET "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags))
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags))
r.HandleFunc("PUT "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag))
r.HandleFunc("DELETE "+config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.DeleteTag))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw))
r.HandleFunc("POST "+config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck))
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck))
if config.EnableSpamAssassin != "" {
r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck))
}
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage))
r.HandleFunc("GET "+config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo))
r.HandleFunc("GET "+config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig))
r.HandleFunc("GET "+config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath))
// Chaos
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos)).Methods("PUT")
r.HandleFunc("GET "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.GetChaos))
r.HandleFunc("PUT "+config.Webroot+"api/v1/chaos", middleWareFunc(apiv1.SetChaos))
// Prometheus metrics (if enabled and using existing server)
if prometheus.GetMode() == "integrated" {
r.HandleFunc(config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
r.HandleFunc("GET "+config.Webroot+"metrics", middleWareFunc(func(w http.ResponseWriter, r *http.Request) {
prometheus.GetHandler().ServeHTTP(w, r)
})).Methods("GET")
}))
}
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", middleWareFunc(apiWebsocket)).Methods("GET")
r.HandleFunc("GET "+config.Webroot+"api/events", middleWareFunc(apiWebsocket))
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
r.Handle("OPTIONS "+config.Webroot+"api/v1/", middleWareFunc(apiv1.GetOptions))
return r
}
@@ -345,6 +350,21 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Webroot, http.StatusFound)
}
// viewHandler routes /view/ requests based on path suffix.
// Go's ServeMux requires wildcards to span a full path segment,
// so patterns like /view/{id}.html are invalid; we dispatch manually here.
func viewHandler(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, config.Webroot+"view/")
switch {
case strings.HasSuffix(path, ".html"):
apiv1.GetMessageHTML(w, r)
case strings.HasSuffix(path, ".txt"):
apiv1.GetMessageText(w, r)
default:
index(w, r)
}
}
// Websocket to broadcast changes.
// Authentication and CORS are handled by middleWareFunc before this is reached.
func apiWebsocket(w http.ResponseWriter, r *http.Request) {