diff --git a/cmd/root.go b/cmd/root.go index ddb9b89..1224abf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -92,6 +92,7 @@ func init() { rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data") rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store") rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)") + rootCmd.Flags().IntVar(&config.MaxMessageSize, "max-message-size", config.MaxMessageSize, "Maximum message size in MB (0 = unlimited)") rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates") rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-ID)") rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout") @@ -219,6 +220,9 @@ func initConfigFromEnv() { if len(os.Getenv("MP_MAX_AGE")) > 0 { config.MaxAge = os.Getenv("MP_MAX_AGE") } + if len(os.Getenv("MP_MAX_MESSAGE_SIZE")) > 0 { + config.MaxMessageSize, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGE_SIZE")) + } if getEnabledFromEnv("MP_USE_MESSAGE_DATES") { config.UseMessageDates = true } diff --git a/config/config.go b/config/config.go index bc10097..3771d64 100644 --- a/config/config.go +++ b/config/config.go @@ -125,6 +125,11 @@ var ( // however some servers accept more. SMTPMaxRecipients = 100 + // MaxMessageSize is the maximum size of an inbound message, in megabytes (MiB). + // Applies to both SMTP DATA payloads and the HTTP /api/v1/send body. + // 0 disables the limit (not recommended on network-reachable listeners). + MaxMessageSize = 50 + // IgnoreDuplicateIDs will skip messages with the same ID IgnoreDuplicateIDs bool @@ -325,6 +330,10 @@ func VerifyConfig() error { return errors.New("[ui] HTTP bind should be in the format of :") } + if MaxMessageSize == 0 { + logger.Log().Warnf("[smtpd] no message limit set, this is not recommended for network-reachable listeners") + } + // Web UI & API if UIAuthFile != "" { UIAuthFile = filepath.Clean(UIAuthFile) diff --git a/internal/smtpd/main.go b/internal/smtpd/main.go index af3b4f3..036f991 100644 --- a/internal/smtpd/main.go +++ b/internal/smtpd/main.go @@ -247,6 +247,10 @@ func listenAndServe(addr string, handler MsgIDHandler, authHandler AuthHandler) }, } + if config.MaxMessageSize > 0 { + srv.MaxSize = config.MaxMessageSize * 1024 * 1024 + } + if config.Label != "" { srv.AppName = fmt.Sprintf("Mailpit (%s)", config.Label) } diff --git a/server/apiv1/send.go b/server/apiv1/send.go index 0d601a4..b3374c3 100644 --- a/server/apiv1/send.go +++ b/server/apiv1/send.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net" @@ -42,11 +43,19 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) { return } + if config.MaxMessageSize > 0 { + r.Body = http.MaxBytesReader(w, r.Body, int64(config.MaxMessageSize)*1024*1024) + } + decoder := json.NewDecoder(r.Body) data := sendMessageParams{} if err := decoder.Decode(&data.Body); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + } httpJSONError(w, err.Error()) return } diff --git a/server/server_test.go b/server/server_test.go index 5d3b4e0..a17862d 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -328,6 +328,53 @@ func TestAPIv1Send(t *testing.T) { assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content") } +func TestAPIv1SendMaxMessageSize(t *testing.T) { + setup() + defer storage.Close() + + r := apiRoutes() + + ts := httptest.NewServer(r) + defer ts.Close() + + original := config.MaxMessageSize + defer func() { config.MaxMessageSize = original }() + + config.MaxMessageSize = 1 // 1 MiB cap for the test + + bigText := strings.Repeat("X", 2*1024*1024) + oversized := fmt.Sprintf(`{ + "From": {"Email": "a@example.com"}, + "To": [{"Email": "b@example.com"}], + "Subject": "oversize", + "Text": %q + }`, bigText) + + t.Log("Sending oversize message via HTTP API (expect 413)") + req, err := http.NewRequest("POST", ts.URL+"/api/v1/send", strings.NewReader(oversized)) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("unexpected transport error: %s", err) + } + _ = resp.Body.Close() + assertEqual(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 413 for oversize body") + + t.Log("Sending normal-sized message via HTTP API (expect 200)") + jsonData, _ := json.Marshal(testSendMessage) + if _, err := clientPost(ts.URL+"/api/v1/send", string(jsonData)); err != nil { + t.Errorf("expected success for in-bound payload, got: %s", err) + } + + t.Log("Setting MaxMessageSize=0 (unlimited), oversize should now succeed") + config.MaxMessageSize = 0 + if _, err := clientPost(ts.URL+"/api/v1/send", oversized); err != nil { + t.Errorf("expected success when MaxMessageSize=0, got: %s", err) + } +} + func TestSendAPIAuthMiddleware(t *testing.T) { setup() defer storage.Close()