diff --git a/go.mod b/go.mod index 5a8a337..5e15175 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index dd501df..7aafa73 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/apiv1/message.go b/server/apiv1/message.go index ed833ac..c65182b 100644 --- a/server/apiv1/message.go +++ b/server/apiv1/message.go @@ -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" { diff --git a/server/apiv1/other.go b/server/apiv1/other.go index 3b908e5..75ff688 100644 --- a/server/apiv1/other.go +++ b/server/apiv1/other.go @@ -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 diff --git a/server/apiv1/release.go b/server/apiv1/release.go index f2c8e03..8883210 100644 --- a/server/apiv1/release.go +++ b/server/apiv1/release.go @@ -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 { diff --git a/server/apiv1/tags.go b/server/apiv1/tags.go index 5c59cc6..1c5ce73 100644 --- a/server/apiv1/tags.go +++ b/server/apiv1/tags.go @@ -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()) diff --git a/server/apiv1/testing.go b/server/apiv1/testing.go index a9b255f..18350dd 100644 --- a/server/apiv1/testing.go +++ b/server/apiv1/testing.go @@ -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 diff --git a/server/apiv1/thumbnails.go b/server/apiv1/thumbnails.go index 6cedd56..f2c6367 100644 --- a/server/apiv1/thumbnails.go +++ b/server/apiv1/thumbnails.go @@ -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 { diff --git a/server/server.go b/server/server.go index 82777b5..a41f82c 100644 --- a/server/server.go +++ b/server/server.go @@ -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) {