Compare commits

...

24 Commits

Author SHA1 Message Date
Ralph Slooten
43b8ba3dc6 Merge branch 'release/v1.29.0' 2026-02-01 16:12:00 +13:00
Ralph Slooten
d41eca3df7 Release v1.29.0 2026-02-01 16:11:59 +13:00
Ralph Slooten
e6fd638067 Detect if copy to clipboard is supported 2026-02-01 16:09:49 +13:00
Ralph Slooten
e2b1b2d0fe Code cleanup 2026-02-01 15:58:31 +13:00
Ralph Slooten
9b4ec97483 Minor UI tweaks 2026-02-01 15:44:13 +13:00
Ralph Slooten
e735904167 Chore: Update node dependencies 2026-02-01 15:40:59 +13:00
Ralph Slooten
94113222cc Chore: Update Go dependencies 2026-02-01 15:37:40 +13:00
Ralph Slooten
5414695508 Test: Add message summary attachment checksum tests 2026-02-01 15:34:06 +13:00
Ralph Slooten
dd74d46880 Feature: Option to display/hide attachment information in message view in web UI including checksums, content type & disposition
Resolves #625
2026-02-01 15:34:06 +13:00
Ralph Slooten
0bfbb4cc5f Feature: Include message attachment checksums (MD5, SHA1 & SHA254) in API message summary 2026-02-01 15:34:05 +13:00
Ralph Slooten
38c0c4fd47 Update webhook delay flag description 2026-02-01 15:34:05 +13:00
Roman Urbanovich
9391b075d0 Chore: Add support for webhook delay (#627) 2026-02-01 15:33:54 +13:00
Ralph Slooten
a87b2a9455 Update API CORS flag description 2026-02-01 15:33:53 +13:00
Ralph Slooten
8d18618e4a Test: Add CORS tests 2026-02-01 15:33:53 +13:00
Ralph Slooten
a63bcd9bd3 Chore: Add support for multi-origin CORS settings and apply to events websocket (#630) 2026-02-01 15:33:53 +13:00
Ralph Slooten
f33f9bec2d Merge branch 'release/v1.28.4' 2026-01-25 10:07:35 +13:00
Ralph Slooten
ff47ba96b8 Release v1.28.4 2026-01-25 10:07:35 +13:00
Ralph Slooten
b9f36312d7 Fix: Avoid error on image type assertion in thumbnail generation
Use imaging.Clone to ensure the image is always *image.NRGBA, preventing panics when decoding non-NRGBA images (e.g., JPEGs as *image.YCbCr).
2026-01-25 10:05:39 +13:00
Ralph Slooten
291c449591 Chore: Update node dependencies 2026-01-25 10:05:38 +13:00
Ralph Slooten
d7a4a60536 Chore: Update Go dependencies 2026-01-25 10:05:38 +13:00
Ralph Slooten
464ff68c34 Fix: Prevent nested MAIL command during an active SMTP transaction (#623) 2026-01-25 10:05:28 +13:00
Ralph Slooten
9383c5876b Fix: Ensure SMTP HELO/EHLO command is issued before MAIL FROM as per RFC 5321 (#621) 2026-01-23 17:27:13 +13:00
Ralph Slooten
a3616e52d9 Chore: Increase allowed SMTP email address length to 1024 chars & return clearer SMTP responses for failures (#620)
This goes against the RFC5321 recommendation, however enforcing the recommended limits is clearly causing issues with users, and it appears no investigated SMTP servers enforce the strict limits either.
2026-01-23 16:46:29 +13:00
Ralph Slooten
980e54c21f Merge tag 'v1.28.3' into develop
Release v1.28.3
2026-01-18 21:36:02 +13:00
24 changed files with 1083 additions and 575 deletions

View File

@@ -2,6 +2,31 @@
Notable changes to Mailpit will be documented in this file.
## [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]
### Security

View File

@@ -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")

12
go.mod
View File

@@ -29,7 +29,7 @@ require (
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
golang.org/x/time v0.14.0
modernc.org/sqlite v1.44.1
modernc.org/sqlite v1.44.3
)
require (
@@ -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.0 // 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
@@ -57,8 +57,8 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
github.com/olekukonko/errors v1.2.0 // 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
)

24
go.sum
View File

@@ -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.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/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=
@@ -95,10 +95,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.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/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 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=
@@ -272,8 +272,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas=
modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -362,8 +362,9 @@ func (s *session) serve() {
// otherwise results in a 5s timeout for each connection
defer func(c net.Conn) { _ = c.Close() }(s.conn)
var gotEHLO bool
var from string
var gotFrom bool
var gotFROM bool
var to []string
var hasRejectedRecipients bool
var buffer bytes.Buffer
@@ -397,8 +398,9 @@ loop:
s.writef("250 %s greets %s", s.srv.Hostname, s.remoteName)
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET, so reset for HELO too.
gotEHLO = true
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -407,8 +409,9 @@ loop:
s.writef("%s", s.makeEHLOResponse())
// RFC 2821 section 4.1.4 specifies that EHLO has the same effect as RSET.
gotEHLO = true
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -421,10 +424,22 @@ loop:
s.writef("530 5.7.0 Authentication required")
break
}
if !gotEHLO {
s.writef("503 5.5.1 Bad sequence of commands (HELO/EHLO required before MAIL)")
break
}
if to != nil {
s.writef("503 5.5.1 Bad sequence of commands (RSET/HELO/EHLO required before MAIL)")
break
}
match := extractAndValidateAddress(mailFromRE, args)
match, err := extractAndValidateAddress(mailFromRE, args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
if err != nil {
s.writef("%s", err.Error())
} else {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid FROM parameter)")
}
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Sender.Trigger(); fail {
@@ -438,7 +453,7 @@ loop:
if sizeMatch == nil {
// ignore other parameter
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
} else {
// Enforce the maximum message size if one is set.
@@ -450,13 +465,13 @@ loop:
s.writef("%s", err.Error())
} else { // SIZE ok
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
}
}
} else { // No parameters after FROM
from = match[1]
gotFrom = true
gotFROM = true
s.writef("250 2.1.0 Ok")
}
}
@@ -473,14 +488,18 @@ loop:
s.writef("530 5.7.0 Authentication required")
break
}
if !gotFrom {
if !gotFROM {
s.writef("503 5.5.1 Bad sequence of commands (MAIL required before RCPT)")
break
}
match := extractAndValidateAddress(rcptToRE, args)
match, err := extractAndValidateAddress(rcptToRE, args)
if match == nil {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
if err != nil {
s.writef("%s", err.Error())
} else {
s.writef("501 5.5.4 Syntax error in parameters or arguments (invalid TO parameter)")
}
} else {
// Mailpit Chaos
if fail, code := chaos.Config.Recipient.Trigger(); fail {
@@ -516,7 +535,7 @@ loop:
break
}
hasRecipients := len(to) > 0 || hasRejectedRecipients
if !gotFrom || !hasRecipients {
if !gotFROM || !hasRecipients {
s.writef("503 5.5.1 Bad sequence of commands (MAIL & RCPT required before DATA)")
break
}
@@ -594,7 +613,7 @@ loop:
// Reset for next mail.
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -608,7 +627,7 @@ loop:
}
s.writef("250 2.0.0 Ok")
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -685,7 +704,7 @@ loop:
// RFC 3207 specifies that the server must discard any prior knowledge obtained from the client.
s.remoteName = ""
from = ""
gotFrom = false
gotFROM = false
to = nil
hasRejectedRecipients = false
buffer.Reset()
@@ -707,7 +726,7 @@ loop:
}
// RFC 4954 specifies that AUTH is not permitted during mail transactions.
if gotFrom || len(to) > 0 {
if gotFROM || len(to) > 0 {
s.writef("503 5.5.1 Bad sequence of commands (AUTH not permitted during mail transaction)")
break
}
@@ -1017,31 +1036,33 @@ func (s *session) handleAuthCramMD5() (bool, error) {
}
// Extract and validate email address from a regex match.
// This ensures that only RFC 5322 email addresses are accepted (if set).
func extractAndValidateAddress(re *regexp.Regexp, args string) []string {
// This ensures that only RFC 5322 compliant email addresses are accepted (if set).
func extractAndValidateAddress(re *regexp.Regexp, args string) ([]string, error) {
match := re.FindStringSubmatch(args)
if match == nil || strings.Contains(match[1], " ") {
return nil
if match == nil {
return nil, nil
}
if strings.Contains(match[1], " ") {
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
}
// first argument will be the email address, validate it if not empty
if match[1] != "" {
a, err := mail.ParseAddress(match[1])
if err != nil {
return nil
}
parts := strings.SplitN(a.Address, "@", 2)
if len(parts) != 2 {
return nil
return nil, errors.New("553 5.1.3 The address is not a valid RFC 5321 address")
}
// https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1
if len(parts[0]) > 64 || len(parts[1]) > 255 || len(a.Address) > 256 {
return nil
// RFC states that the local part of an email address SHOULD not exceed 64 characters
// and the domain part SHOULD not exceed 255 characters, however as per https://github.com/axllent/mailpit/issues/620
// it appears that investigated mail servers do not actually implement this limit, but rather enforce
// a much larger limit (ie: 1024 characters).
if len(a.Address) > 1024 {
return nil, errors.New("500 The address is too long")
}
}
return match
return match, nil
}

View File

@@ -106,15 +106,14 @@ func TestCmdEHLO(t *testing.T) {
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
// test invalid addresses & header injection
cmdCode(t, conn, "RCPT TO: <recipientrecipientrecipientrecipientrecipientrecipientrecipientrecipientrecipientrecipientrecipientrecipient@exampleexampleexampleexampleexampleexampleexample.com>", "501") // too long
cmdCode(t, conn, "RCPT TO: <recipientrecipientrecipientreciprecipientrecipientrecipientrecipt@exaample.com>", "501")
cmdCode(t, conn, "RCPT TO: <recipient@examplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <r@examplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexample.com>", "250") // valid
cmdCode(t, conn, "RCPT TO:<recipientexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <recipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipientreciprecipientrecipientrecipientreciptrecipientrecipientrecipient@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
cmdCode(t, conn, "RCPT TO:<recipientexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient@test@example.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient@@example.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipientexample.com>", "501")
cmdCode(t, conn, "RCPT TO:<recipient\rexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "501")
cmdCode(t, conn, "RCPT TO:<recipient\rexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "553")
cmdCode(t, conn, "RCPT TO: <recipient\rexample.com>", "501")
cmdCode(t, conn, "RCPT TO: <>", "501") // empty address not allowed here
@@ -125,6 +124,41 @@ func TestCmdEHLO(t *testing.T) {
_ = conn.Close()
}
func TestCmdMAILBeforeEHLO(t *testing.T) {
conn := newConn(t, &Server{})
// RFC 5321 §4.1.4 — Order of Commands states (emphasis added):
// “The SMTP client MUST issue HELO or EHLO before any other SMTP commands.”
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "503")
cmdCode(t, conn, "QUIT", "221")
_ = conn.Close()
}
func TestCmdMAILAfterRCPT(t *testing.T) {
conn := newConn(t, &Server{})
// Send EHLO, expect greeting
cmdCode(t, conn, "EHLO host.example.com", "250")
// Send MAIL FROM
cmdCode(t, conn, "MAIL FROM:<sender@example.com>", "250")
// Send RCPT TO
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")
// MAIL FROM must not come after RCPT TO in the same transaction
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "503")
// RSET to clear the transaction
cmdCode(t, conn, "RSET", "250")
// Now the MAIL FROM should be accepted
cmdCode(t, conn, "MAIL FROM:<sender2@example.com>", "250")
cmdCode(t, conn, "QUIT", "221")
_ = conn.Close()
}
func TestCmdRSET(t *testing.T) {
conn := newConn(t, &Server{})
cmdCode(t, conn, "EHLO host.example.com", "250")
@@ -145,7 +179,7 @@ func TestCmdMAIL(t *testing.T) {
// MAIL with no FROM arg should return 501 syntax error
cmdCode(t, conn, "MAIL", "501")
// MAIL with empty FROM arg should return 501 syntax error
// // MAIL with empty FROM arg should return 501 syntax error
cmdCode(t, conn, "MAIL FROM:", "501")
cmdCode(t, conn, "MAIL FROM: ", "501")
cmdCode(t, conn, "MAIL FROM: ", "501")
@@ -160,19 +194,18 @@ func TestCmdMAIL(t *testing.T) {
cmdCode(t, conn, "MAIL FROM: <sender@example.com>", "501")
// test invalid addresses & header injection
cmdCode(t, conn, "MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersender@exampleexampleexampleexampleexampleexampleexample.com>", "501") // too long
cmdCode(t, conn, "MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersender@exaample.com>", "501")
cmdCode(t, conn, "MAIL FROM: <sender@examplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: <s@examplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexamplexampleexampleexampleexampleexampleexampleexample.com>", "250") // valid
cmdCode(t, conn, "MAIL FROM:<sender\rexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: <sendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersendersender@exampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexampleexample.com>", "500") // too long
cmdCode(t, conn, "MAIL FROM:<sender\rexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender\rexample.com>", "501")
cmdCode(t, conn, "MAIL FROM:<senderexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "501")
cmdCode(t, conn, "MAIL FROM:<senderexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@test@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "553")
cmdCode(t, conn, "MAIL FROM: <senderexample.com>", "501")
cmdCode(t, conn, "MAIL FROM: < sender@example.com >", "501")
cmdCode(t, conn, "MAIL FROM: < sender@example.com>", "501")
cmdCode(t, conn, "MAIL FROM: <sender@example.com >", "501")
cmdCode(t, conn, "MAIL FROM: < sender@example.com >", "553")
cmdCode(t, conn, "MAIL FROM: < sender@example.com>", "553")
cmdCode(t, conn, "MAIL FROM: <sender@example.com >", "553")
// MAIL with valid SIZE parameter should return 250 Ok
cmdCode(t, conn, "MAIL FROM:<sender@example.com> SIZE=1000", "250")
@@ -241,6 +274,7 @@ func TestCmdRCPT(t *testing.T) {
cmdCode(t, conn, "RCPT TO:", "501")
cmdCode(t, conn, "RCPT TO: ", "501")
cmdCode(t, conn, "RCPT TO: ", "501")
cmdCode(t, conn, "RCPT TO:<@route.example user@example.com>", "553")
// RCPT with valid TO arg should return 250 Ok
cmdCode(t, conn, "RCPT TO:<recipient@example.com>", "250")

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
})

View File

@@ -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

View File

@@ -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

View File

@@ -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())

891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -76,13 +76,14 @@ func Thumbnail(w http.ResponseWriter, r *http.Request) {
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
var temp image.Image
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos).(*image.NRGBA)
temp = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos).(*image.NRGBA)
temp = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
dstImageFill := imaging.Clone(temp)
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)

127
server/cors.go Normal file
View File

@@ -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
}

119
server/cors_test.go Normal file
View File

@@ -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)
}
})
}
}

View File

@@ -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()
}

View File

@@ -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())

View File

@@ -1,5 +1,6 @@
<script>
import commonMixins from "../../mixins/CommonMixins";
import { mailbox } from "../../stores/mailbox";
import ICAL from "ical.js";
import dayjs from "dayjs";
@@ -19,6 +20,7 @@ export default {
data() {
return {
mailbox,
ical: false,
};
},
@@ -74,46 +76,125 @@ export default {
</script>
<template>
<div class="mt-4 border-top pt-4">
<a
<hr />
<button
class="btn btn-sm btn-outline-secondary mb-3"
@click="mailbox.showAttachmentDetails = !mailbox.showAttachmentDetails"
>
<i class="bi me-1" :class="mailbox.showAttachmentDetails ? 'bi-eye-slash' : 'bi-eye'"></i>
{{ mailbox.showAttachmentDetails ? "Hide" : "Show" }} attachment details
</button>
<div class="row gx-1 w-100">
<div
v-for="part in attachments"
:key="part.PartID"
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3"
target="_blank"
style="width: 180px"
@click="openAttachment(part, $event)"
:class="mailbox.showAttachmentDetails ? 'col-12' : 'col-auto'"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top"
alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i>
<div class="row gx-1 mb-3">
<div class="col-auto">
<a
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3"
target="_blank"
style="width: 180px"
@click="openAttachment(part, $event)"
>
<img
v-if="isImage(part)"
:src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top"
alt=""
/>
<img
v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="
class="card-img-top"
alt=""
/>
<div v-if="!isImage(part)" class="icon">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
<p class="mb-1">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div>
</a>
</div>
<div v-if="mailbox.showAttachmentDetails" class="col">
<h5 class="mb-1">
<a
:href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="me-2"
@click="openAttachment(part, $event)"
>
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</a>
<small class="text-muted fw-light">
<small>({{ getFileSize(part.Size) }})</small>
</small>
</h5>
<p class="mb-1 small"><strong>Disposition</strong>: {{ part.ContentDisposition }}</p>
<p class="mb-2 small">
<strong>Content type</strong>: <code>{{ part.ContentType }}</code>
</p>
<p class="m-0 small">
<strong>MD5</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-sm btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.MD5, $event)"
>
{{ part.Checksums.MD5 }}
<i v-if="!copiedText[part.Checksums.MD5]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.MD5 }}</code>
</p>
<p class="m-0 small">
<strong>SHA1</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.SHA1, $event)"
>
{{ part.Checksums.SHA1 }}
<i v-if="!copiedText[part.Checksums.SHA1]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.SHA1 }}</code>
</p>
<p class="m-0 small">
<strong>SHA256</strong>:
<button
v-if="copyToClipboardSupported"
class="btn btn-sm btn-link p-0"
title="Click to copy to clipboard"
@click="copyToClipboard(part.Checksums.SHA256, $event)"
>
{{ part.Checksums.SHA256 }}
<i v-if="!copiedText[part.Checksums.SHA256]" class="bi bi-clipboard ms-1"></i>
<i v-else class="bi bi-check2-square ms-1 text-success"></i>
</button>
<code v-else>{{ part.Checksums.SHA256 }}</code>
</p>
</div>
</div>
<div class="card-body border-0">
<p class="mb-1">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != "" ? part.FileName : "[ unknown ]" + part.ContentType }}
</div>
</a>
</div>
</div>
<!-- ICS Modal -->
<div id="ICSView" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content">

View File

@@ -20,9 +20,16 @@ export default {
return {
loading: 0,
tagColorCache: {},
copiedText: {}, // used for clipboard copy feedback
};
},
computed: {
copyToClipboardSupported() {
return !!navigator.clipboard;
},
},
methods: {
resolve(u) {
return this.$router.resolve(u).href;
@@ -222,12 +229,15 @@ export default {
allAttachments(message) {
const a = [];
for (const i in message.Attachments) {
message.Attachments[i].ContentDisposition = "Attachment";
a.push(message.Attachments[i]);
}
for (const i in message.OtherParts) {
message.OtherParts[i].ContentDisposition = "Other";
a.push(message.OtherParts[i]);
}
for (const i in message.Inline) {
message.Inline[i].ContentDisposition = "Inline";
a.push(message.Inline[i]);
}
@@ -288,5 +298,21 @@ export default {
return this.tagColorCache[s];
},
// Copy to clipboard functionality
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(
() => {
this.copiedText[text] = true;
setTimeout(() => {
delete this.copiedText[text];
}, 2000);
},
() => {
// failure
alert("Failed to copy to clipboard");
},
);
},
},
};

View File

@@ -32,6 +32,7 @@ export const mailbox = reactive({
timeZone: localStorage.getItem("timeZone")
? localStorage.getItem("timeZone")
: Intl.DateTimeFormat().resolvedOptions().timeZone,
showAttachmentDetails: localStorage.getItem("showAttachmentDetails"), // show attachment details
});
watch(
@@ -106,3 +107,14 @@ watch(
}
},
);
watch(
() => mailbox.showAttachmentDetails,
(v) => {
if (v) {
localStorage.setItem("showAttachmentDetails", "1");
} else {
localStorage.removeItem("showAttachmentDetails");
}
},
);

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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.