diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d5619..fcecbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,30 @@ Notable changes to Mailpit will be documented in this file. -## [v1.28.4] +## [v1.29.0] + +### Feature +- Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary +- Option to display/hide attachment information in message view in web UI including checksums, content type & disposition ### Chore - Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures ([#620](https://github.com/axllent/mailpit/issues/620)) - Update Go dependencies - Update node dependencies +- Add support for multi-origin CORS settings and apply to events websocket ([#630](https://github.com/axllent/mailpit/issues/630)) +- Add support for webhook delay ([#627](https://github.com/axllent/mailpit/issues/627)) +- Update Go dependencies +- Update node dependencies ### Fix - Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 ([#621](https://github.com/axllent/mailpit/issues/621)) - Prevent nested MAIL command during an active SMTP transaction ([#623](https://github.com/axllent/mailpit/issues/623)) - Avoid error on image type assertion in thumbnail generation +### Test +- Add CORS tests +- Add message summary attachment checksum tests + ## [v1.28.3] diff --git a/cmd/root.go b/cmd/root.go index 3011047..a2a145e 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)") @@ -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 (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") @@ -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/go.mod b/go.mod index e989b18..bb7fc4f 100644 --- a/go.mod +++ b/go.mod @@ -38,9 +38,9 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/displaywidth v0.7.0 // indirect + github.com/clipperhouse/displaywidth v0.8.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.1 // indirect + github.com/clipperhouse/uax29/v2 v2.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect @@ -58,7 +58,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect - github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect + github.com/olekukonko/ll v0.1.4 // indirect github.com/olekukonko/tablewriter v1.1.3 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -75,7 +75,7 @@ require ( golang.org/x/mod v0.32.0 // indirect golang.org/x/sys v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - modernc.org/libc v1.67.6 // indirect + modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 4d415be..720d842 100644 --- a/go.sum +++ b/go.sum @@ -16,12 +16,12 @@ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= -github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.8.0 h1:/z8v+H+4XLluJKS7rAc7uHZTalT5Z+1430ld3lePSRI= +github.com/clipperhouse/displaywidth v0.8.0/go.mod h1:UpOXiIKep+TohQYwvAAM/VDU8v3Z5rnWTxiwueR0XvQ= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw= -github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= +github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -97,8 +97,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE= +github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -262,8 +262,8 @@ modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= +modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 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/messages_test.go b/internal/storage/messages_test.go index 5bcf0d5..4f46cbe 100644 --- a/internal/storage/messages_test.go +++ b/internal/storage/messages_test.go @@ -263,6 +263,11 @@ func TestRegularAttachmentHandling(t *testing.T) { if msg.Attachments[0].ContentID != "" { t.Errorf("Test case 3: Expected empty ContentID, got '%s'", msg.Attachments[0].ContentID) } + + // Checksum tests + assertEqual(t, msg.Attachments[0].Checksums.MD5, "b04930eb1ba0c62066adfa87e5d262c4", "Attachment MD5 checksum does not match") + assertEqual(t, msg.Attachments[0].Checksums.SHA1, "15605d6a2fca44e966209d1701f16ecf816df880", "Attachment SHA1 checksum does not match") + assertEqual(t, msg.Attachments[0].Checksums.SHA256, "92c4ccff376003381bd9054d3da7b32a3c5661905b55e3b0728c17aba6d223ec", "Attachment SHA256 checksum does not match") } func TestMixedAttachmentHandling(t *testing.T) { diff --git a/internal/storage/reindex.go b/internal/storage/reindex.go index acc3aa9..b573070 100644 --- a/internal/storage/reindex.go +++ b/internal/storage/reindex.go @@ -27,7 +27,7 @@ func ReindexAll() { err := sqlf.Select("ID").To(&i). From(tenant("mailbox")). OrderBy("Created DESC"). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) { + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { ids = append(ids, i) }) diff --git a/internal/storage/settings.go b/internal/storage/settings.go index 5d3660f..22f3da9 100644 --- a/internal/storage/settings.go +++ b/internal/storage/settings.go @@ -15,7 +15,7 @@ func SettingGet(k string) string { Select("Value").To(&result). Where("Key = ?", k). Limit(1). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return "" @@ -41,7 +41,7 @@ func getDeletedSize() uint64 { Select("Value").To(&result). Where("Key = ?", "DeletedSize"). Limit(1). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return 0 @@ -55,7 +55,7 @@ func totalMessagesSize() uint64 { var result sql.NullFloat64 err := sqlf.From(tenant("mailbox")). Select("SUM(Size)").To(&result). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) {}) if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return 0 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/internal/storage/tags.go b/internal/storage/tags.go index 3627069..0d319c2 100644 --- a/internal/storage/tags.go +++ b/internal/storage/tags.go @@ -147,7 +147,7 @@ func GetAllTags() []string { Select(`DISTINCT Name`). From(tenant("tags")).To(&name). OrderBy("Name"). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) { + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags = append(tags, name) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) @@ -169,7 +169,7 @@ func GetAllTagsCount() map[string]int64 { LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")). GroupBy(tenant("message_tags.TagID")). OrderBy("Name"). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) { + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags[name] = int64(total) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) @@ -352,7 +352,7 @@ func getMessageTags(id string) []string { LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")). Where(tenant("message_tags.ID")+` = ?`, id). OrderBy("Name"). - QueryAndClose(context.TODO(), db, func(row *sql.Rows) { + QueryAndClose(context.TODO(), db, func(_ *sql.Rows) { tags = append(tags, name) }); err != nil { logger.Log().Errorf("[tags] %s", err.Error()) diff --git a/package-lock.json b/package-lock.json index 69f03a2..d2dafda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,12 +68,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -83,21 +83,21 @@ } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.6.tgz", - "integrity": "sha512-kz2fAQ5UzjV7X7D3ySxmj3vRq89dTpqOZWv76Z6pNPztkwb/0Yj1Mtx1xFrYj6mbIHysxtBot8J4o0JLCblcFw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", "license": "MIT", "dependencies": { - "core-js-pure": "^3.43.0" + "core-js-pure": "^3.48.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2011,9 +2011,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -3320,9 +3320,9 @@ "license": "MIT" }, "node_modules/modern-screenshot": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.7.tgz", - "integrity": "sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==", + "version": "4.6.8", + "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.8.tgz", + "integrity": "sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==", "license": "MIT" }, "node_modules/ms": { @@ -4310,18 +4310,18 @@ } }, "node_modules/swagger-client": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.0.tgz", - "integrity": "sha512-9fkjxGHXuKy20jj8zwE6RwgFSOGKAyOD5U7aKgW/+/futtHZHOdZeqiEkb97sptk2rdBv7FEiUQDNlWZR186RA==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.1.tgz", + "integrity": "sha512-bcYpeN4P3sOoKi22zsxIlL9lSgouBAmQmL5hH4g5yeOvyTUvq1+OFtGTs0l1C5Dkb0ZN+2vNgp0FBAFulmUklA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.22.15", "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": "^1.0.0-rc.1", - "@swagger-api/apidom-error": "^1.0.0-rc.1", - "@swagger-api/apidom-json-pointer": "^1.0.0-rc.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-rc.1", - "@swagger-api/apidom-reference": "^1.0.0-rc.1", + "@swagger-api/apidom-core": "^1.3.0", + "@swagger-api/apidom-error": "^1.3.0", + "@swagger-api/apidom-json-pointer": "^1.3.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.3.0", + "@swagger-api/apidom-reference": "^1.3.0", "@swaggerexpert/cookie": "^2.0.2", "deepmerge": "~4.3.0", "fast-json-patch": "^3.0.0-1", @@ -4350,9 +4350,9 @@ } }, "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "dev": true, "license": "MIT", "peer": true, 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/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) + } + }) + } +} 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/server_test.go b/server/server_test.go index a4ee108..0ff9934 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -130,14 +130,14 @@ func TestAPIv1ToggleReadStatus(t *testing.T) { // read first 10 IDs t.Log("Get first 10 IDs") - putIDS := []string{} + putIDs := []string{} for idx, msg := range m.Messages { if idx == 10 { break } // store for later - putIDS = append(putIDS, msg.ID) + putIDs = append(putIDs, msg.ID) } assertStatsEqual(t, ts.URL+"/api/v1/messages", 100, 100) @@ -145,7 +145,7 @@ func TestAPIv1ToggleReadStatus(t *testing.T) { t.Log("Mark first 10 as read") putData := putDataStruct putData.Read = true - putData.IDs = putIDS + putData.IDs = putIDs j, err := json.Marshal(putData) if err != nil { t.Error(err.Error()) diff --git a/server/ui-src/components/message/MessageAttachments.vue b/server/ui-src/components/message/MessageAttachments.vue index 66ecd3d..4b29334 100644 --- a/server/ui-src/components/message/MessageAttachments.vue +++ b/server/ui-src/components/message/MessageAttachments.vue @@ -1,5 +1,6 @@