From a63bcd9bd31923305ea88f9514e4ae8bab2a2250 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 31 Jan 2026 22:54:32 +1300 Subject: [PATCH 01/14] Chore: Add support for multi-origin CORS settings and apply to events websocket (#630) --- server/cors.go | 127 ++++++++++++++++++++++++++++++++++++ server/server.go | 23 +++++-- server/websockets/client.go | 4 ++ 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 server/cors.go diff --git a/server/cors.go b/server/cors.go new file mode 100644 index 0000000..8a1cf3f --- /dev/null +++ b/server/cors.go @@ -0,0 +1,127 @@ +package server + +import ( + "net/http" + "net/url" + "sort" + "strings" + + "github.com/axllent/mailpit/internal/logger" +) + +var ( + // AccessControlAllowOrigin CORS policy - set with flags/env + AccessControlAllowOrigin string + + // CorsAllowOrigins are optional allowed origins by hostname, set via setCORSOrigins(). + corsAllowOrigins = make(map[string]bool) +) + +// equalASCIIFold reports whether s and t, interpreted as UTF-8 strings, are equal +// under Unicode case folding, ignoring any difference in length. +func asciiFoldString(s string) string { + b := make([]byte, len(s)) + for i := range s { + b[i] = toLowerASCIIFold(s[i]) + } + return string(b) +} + +// toLowerASCIIFold returns the Unicode case-folded equivalent of the ASCII character c. +// It is equivalent to the Unicode 13.0.0 function foldCase(c, CaseFoldingMapping). +func toLowerASCIIFold(c byte) byte { + if 'A' <= c && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} + +// CorsOriginAccessControl checks if the request origin is allowed based on the configured CORS origins. +func corsOriginAccessControl(r *http.Request) bool { + origin := r.Header["Origin"] + + if len(origin) != 0 { + u, err := url.Parse(origin[0]) + if err != nil { + logger.Log().Errorf("CORS origin parse error: %v", err) + return false + } + + _, allAllowed := corsAllowOrigins["*"] + // allow same origin || is "*" is defined as an origin + if asciiFoldString(u.Host) == asciiFoldString(r.Host) || allAllowed { + return true + } + + originHostFold := asciiFoldString(u.Hostname()) + if corsAllowOrigins[originHostFold] { + return true + } + return false + } + + return true +} + +// SetCORSOrigins sets the allowed CORS origins from a comma-separated string. +// It does not consider port or protocol, only the hostname. +func setCORSOrigins() { + corsAllowOrigins = make(map[string]bool) + + hosts := extractOrigins(AccessControlAllowOrigin) + for _, host := range hosts { + corsAllowOrigins[asciiFoldString(host)] = true + } + + if _, wildCard := corsAllowOrigins["*"]; wildCard { + // reset to just wildcard + corsAllowOrigins = make(map[string]bool) + corsAllowOrigins["*"] = true + logger.Log().Info("[cors] all origins are allowed due to wildcard \"*\"") + } else { + keys := make([]string, 0) + for k := range corsAllowOrigins { + keys = append(keys, k) + } + sort.Strings(keys) + logger.Log().Infof("[cors] allowed API origins: %v", strings.Join(keys, ", ")) + } +} + +// extractOrigins extracts and returns a sorted list of origins from a comma-separated string. +func extractOrigins(str string) []string { + origins := make([]string, 0) + s := strings.TrimSpace(str) + if s == "" { + return origins + } + + hosts := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' + }) + + for _, host := range hosts { + h := strings.TrimSpace(host) + if h != "" { + if h == "*" { + return []string{"*"} + } + + if !strings.HasPrefix(h, "http://") && !strings.HasPrefix(h, "https://") { + h = "http://" + h + } + + u, err := url.Parse(h) + if err != nil || u.Hostname() == "" || strings.Contains(h, "*") { + logger.Log().Warnf("[cors] invalid CORS origin \"%s\", ignoring", h) + continue + } + + origins = append(origins, u.Hostname()) + } + } + + sort.Strings(origins) + + return origins +} diff --git a/server/server.go b/server/server.go index 0e5bd2a..17ee3ea 100644 --- a/server/server.go +++ b/server/server.go @@ -32,21 +32,23 @@ import ( ) var ( - // AccessControlAllowOrigin CORS policy - AccessControlAllowOrigin string - // htmlPreviewRouteRe is a regexp to match the HTML preview route htmlPreviewRouteRe *regexp.Regexp ) // Listen will start the httpd func Listen() { + setCORSOrigins() + isReady := &atomic.Value{} isReady.Store(false) stats.Track() websockets.MessageHub = websockets.NewHub() + // set allowed websocket origins from configuration + // websockets.SetAllowedOrigins(AccessControlAllowWSOrigins) + go websockets.MessageHub.Run() go pop3.Run() @@ -287,9 +289,12 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { htmlPreviewRouteRe = regexp.MustCompile(`^` + regexp.QuoteMeta(config.Webroot) + `view/[a-zA-Z0-9]+\.html$`) } - if AccessControlAllowOrigin != "" && - (strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI)) { - w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin) + if strings.HasPrefix(r.RequestURI, config.Webroot+"api/") || htmlPreviewRouteRe.MatchString(r.RequestURI) { + if allowed := corsOriginAccessControl(r); !allowed { + http.Error(w, "Unauthorised.", http.StatusForbidden) + return + } + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") } @@ -331,6 +336,12 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) { // Websocket to broadcast changes func apiWebsocket(w http.ResponseWriter, r *http.Request) { + if allowed := corsOriginAccessControl(r); !allowed { + http.Error(w, "Unauthorised.", http.StatusForbidden) + return + } + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Header().Set("Access-Control-Allow-Headers", "*") websockets.ServeWs(websockets.MessageHub, w, r) storage.BroadcastMailboxStats() } diff --git a/server/websockets/client.go b/server/websockets/client.go index 7902dae..4d44110 100644 --- a/server/websockets/client.go +++ b/server/websockets/client.go @@ -35,6 +35,10 @@ var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, EnableCompression: true, + CheckOrigin: func(_ *http.Request) bool { + // origin is checked via server.go's CORS settings + return true + }, } // Client is a middleman between the websocket connection and the hub. From 8d18618e4a81eb38e6c1245e6e2affebeaf028df Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 31 Jan 2026 22:54:55 +1300 Subject: [PATCH 02/14] Test: Add CORS tests --- server/cors_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 server/cors_test.go diff --git a/server/cors_test.go b/server/cors_test.go new file mode 100644 index 0000000..a272676 --- /dev/null +++ b/server/cors_test.go @@ -0,0 +1,119 @@ +package server + +import ( + "net/http" + "testing" +) + +func TestExtractOrigins(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty string", + input: "", + expected: []string{}, + }, + { + name: "single hostname", + input: "example.com", + expected: []string{"example.com"}, + }, + { + name: "multiple hostnames comma separated", + input: "example.com,foo.com", + expected: []string{"example.com", "foo.com"}, + }, + { + name: "multiple hostnames space separated", + input: "example.com foo.com", + expected: []string{"example.com", "foo.com"}, + }, + { + name: "wildcard", + input: "*", + expected: []string{"*"}, + }, + { + name: "mixed protocols", + input: "http://example.com,https://foo.com:8080", + expected: []string{"example.com", "foo.com"}, + }, + { + + name: "embedded wildcard", + input: "http://example.com,*,https://test", + expected: []string{"*"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractOrigins(tt.input) + + if len(got) != len(tt.expected) { + t.Errorf("expected %d origins, got %d", len(tt.expected), len(got)) + return + } + for i := range got { + if got[i] != tt.expected[i] { + t.Errorf("expected origin %q, got %q", tt.expected[i], got[i]) + } + } + }) + } +} + +func TestCorsOriginAccessControl(t *testing.T) { + // Setup allowed origins + AccessControlAllowOrigin = "example.com,foo.com,bar.com" + setCORSOrigins() + + tests := []struct { + name string + origin string + host string + allow bool + }{ + {"no origin header", "", "example.com", true}, + {"allowed origin", "http://example.com:1234", "mailpit.local", true}, + {"not allowed origin", "http://notallowed.com", "mailpit.local", false}, + {"allowed by hostname", "http://foo.com", "mailpit.local", true}, + {"ascii fold: allowed origin uppercase", "HTTP://EXAMPLE.COM", "mailpit.local", true}, + {"ascii fold: allowed by hostname uppercase", "HTTP://FOO.COM", "mailpit.local", true}, + {"ascii fold: host uppercase", "http://example.com", "MAILPIT.LOCAL", true}, + {"ascii fold: not allowed origin uppercase", "HTTP://NOTALLOWED.COM", "mailpit.local", false}, + {"ascii fold: mixed case", "HtTp://ExAmPlE.CoM", "mailpit.local", true}, + {"non-ascii: allowed origin (unicode hostname)", "http://exámple.com", "mailpit.local", false}, + {"non-ascii: allowed by hostname (unicode)", "http://föö.com", "mailpit.local", false}, + {"non-ascii: host uppercase (unicode)", "http://exámple.com", "MAILPIT.LOCAL", false}, + {"non-ascii: mixed case (unicode)", "HtTp://ExÁmPlE.CoM", "mailpit.local", false}, + } + + // Add wildcard test + AccessControlAllowOrigin = "*" + setCORSOrigins() + reqWildcard := &http.Request{Header: http.Header{"Origin": {"http://any.com"}}, Host: "mailpit.local"} + if !corsOriginAccessControl(reqWildcard) { + t.Error("Wildcard origin should be allowed") + } + + // Reset to specific hosts + AccessControlAllowOrigin = "example.com,foo.com,bar.com" + setCORSOrigins() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{Header: http.Header{}, Host: tt.host} + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + allowed := corsOriginAccessControl(req) + if allowed != tt.allow { + t.Errorf("expected allowed=%v, got %v for origin=%q host=%q", tt.allow, allowed, tt.origin, tt.host) + } + }) + } +} From a87b2a945530ce5e478f8d9682dfb7ab66c30337 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 31 Jan 2026 23:08:15 +1300 Subject: [PATCH 03/14] Update API CORS flag description --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 3011047..6327128 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -103,7 +103,7 @@ func init() { rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication") rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key") rootCmd.Flags().StringVar(&config.UITLSKey, "ui-tls-key", config.UITLSKey, "TLS key for web UI (HTTPS) - requires ui-tls-cert") - rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header") + rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set CORS origin(s) for the API, comma-separated (eg: example.com,foo.com)") rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts") rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin") rootCmd.Flags().BoolVar(&config.AllowUntrustedTLS, "allow-untrusted-tls", config.AllowUntrustedTLS, "Do not verify HTTPS certificates (link checker & screenshots)") From 9391b075d0f3363be421abdad88af5c589a9f274 Mon Sep 17 00:00:00 2001 From: Roman Urbanovich Date: Fri, 23 Jan 2026 13:50:35 +0300 Subject: [PATCH 04/14] Chore: Add support for webhook delay (#627) --- cmd/root.go | 4 ++++ server/webhook/webhook.go | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index 6327128..7030ba4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -160,6 +160,7 @@ func init() { // Webhook rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second") + rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (0 = no delay)") // DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data") @@ -387,6 +388,9 @@ func initConfigFromEnv() { if len(os.Getenv("MP_WEBHOOK_LIMIT")) > 0 { webhook.RateLimit, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_LIMIT")) } + if len(os.Getenv("MP_WEBHOOK_DELAY")) > 0 { + webhook.Delay, _ = strconv.Atoi(os.Getenv("MP_WEBHOOK_DELAY")) + } // Demo mode config.DemoMode = getEnabledFromEnv("MP_DEMO_MODE") diff --git a/server/webhook/webhook.go b/server/webhook/webhook.go index 0b60d76..7f58376 100644 --- a/server/webhook/webhook.go +++ b/server/webhook/webhook.go @@ -16,6 +16,10 @@ var ( // RateLimit is the minimum number of seconds between requests RateLimit = 1 + // Delay is the number of seconds to wait before sending each webhook request + // This can allow for other processing to complete before the webhook is triggered. + Delay = 0 + rl rate.Sometimes rateLimiterSet bool @@ -38,6 +42,11 @@ func Send(msg any) { } go func() { + // Apply delay if configured + if Delay > 0 { + time.Sleep(time.Duration(Delay) * time.Second) + } + rl.Do(func() { b, err := json.Marshal(msg) if err != nil { From 38c0c4fd47393e2e8f8ed120f082f2b2e901f591 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 31 Jan 2026 23:13:03 +1300 Subject: [PATCH 05/14] Update webhook delay flag description --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 7030ba4..a2a145e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -160,7 +160,7 @@ func init() { // Webhook rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second") - rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (0 = no delay)") + rootCmd.Flags().IntVar(&webhook.Delay, "webhook-delay", webhook.Delay, "Delay in seconds before sending webhook requests (default 0)") // DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data") From 0bfbb4cc5f941b94d92dbef82edae922fc6aaf60 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 1 Feb 2026 14:58:36 +1300 Subject: [PATCH 06/14] Feature: Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary --- internal/storage/messages.go | 11 +++++++++++ internal/storage/structs.go | 11 ++++++++++- server/ui/api/v1/swagger.json | 20 +++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/storage/messages.go b/internal/storage/messages.go index 34d9776..1b41961 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -3,6 +3,9 @@ package storage import ( "bytes" "context" + "crypto/md5" // #nosec + "crypto/sha1" // #nosec + "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" @@ -505,6 +508,14 @@ func AttachmentSummary(a *enmime.Part) Attachment { o.ContentID = a.ContentID o.Size = uint64(len(a.Content)) + md5Hash := md5.Sum(a.Content) // #nosec + sha1Hash := sha1.Sum(a.Content) // #nosec + sha256Hash := sha256.Sum256(a.Content) + + o.Checksums.MD5 = hex.EncodeToString(md5Hash[:]) + o.Checksums.SHA1 = hex.EncodeToString(sha1Hash[:]) + o.Checksums.SHA256 = hex.EncodeToString(sha256Hash[:]) + return o } diff --git a/internal/storage/structs.go b/internal/storage/structs.go index 5bc521b..0e9d93c 100644 --- a/internal/storage/structs.go +++ b/internal/storage/structs.go @@ -48,7 +48,7 @@ type Message struct { Attachments []Attachment } -// Attachment struct for inline and attachments +// Attachment struct for inline images and attachments // // swagger:model Attachment type Attachment struct { @@ -62,6 +62,15 @@ type Attachment struct { ContentID string // Size in bytes Size uint64 + // File checksums + Checksums struct { + // MD5 checksum hash of file + MD5 string + // SHA1 checksum hash of file + SHA1 string + // SHA256 checksum hash of file + SHA256 string + } } // MessageSummary struct for frontend messages diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json index e5fc5fd..581c311 100644 --- a/server/ui/api/v1/swagger.json +++ b/server/ui/api/v1/swagger.json @@ -1345,9 +1345,27 @@ "x-go-package": "github.com/axllent/mailpit/internal/stats" }, "Attachment": { - "description": "Attachment struct for inline and attachments", + "description": "Attachment struct for inline images and attachments", "type": "object", "properties": { + "Checksums": { + "description": "File checksums", + "type": "object", + "properties": { + "MD5": { + "description": "MD5 checksum hash of file", + "type": "string" + }, + "SHA1": { + "description": "SHA1 checksum hash of file", + "type": "string" + }, + "SHA256": { + "description": "SHA256 checksum hash of file", + "type": "string" + } + } + }, "ContentID": { "description": "Content ID", "type": "string" From dd74d468802078846ee3f0cc670e040d50b9a6f5 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sun, 1 Feb 2026 15:19:06 +1300 Subject: [PATCH 07/14] Feature: Option to display/hide attachment information in message view in web UI including checksums, content type & disposition Resolves #625 --- .../components/message/MessageAttachments.vue | 143 +++++++++++++----- server/ui-src/mixins/CommonMixins.js | 20 +++ server/ui-src/stores/mailbox.js | 12 ++ 3 files changed, 141 insertions(+), 34 deletions(-) diff --git a/server/ui-src/components/message/MessageAttachments.vue b/server/ui-src/components/message/MessageAttachments.vue index 66ecd3d..35c3b59 100644 --- a/server/ui-src/components/message/MessageAttachments.vue +++ b/server/ui-src/components/message/MessageAttachments.vue @@ -1,5 +1,6 @@