diff --git a/cmd/root.go b/cmd/root.go index 1472c39..f7ca819 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -158,6 +158,7 @@ func init() { rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file") rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags") rootCmd.Flags().StringVar(&config.TagsDisable, "tags-disable", config.TagsDisable, "Disable auto-tagging, comma separated (eg: plus-addresses,x-tags)") + rootCmd.Flags().BoolVar(&config.TagsUsername, "tags-username", config.TagsUsername, "Auto-tag messages with the authenticated username") // Prometheus metrics rootCmd.Flags().StringVar(&config.PrometheusListen, "enable-prometheus", config.PrometheusListen, "Enable Prometheus metrics: true|false| (eg:'0.0.0.0:9090')") @@ -371,6 +372,7 @@ func initConfigFromEnv() { config.TagsConfig = os.Getenv("MP_TAGS_CONFIG") tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE") config.TagsDisable = os.Getenv("MP_TAGS_DISABLE") + config.TagsUsername = getEnabledFromEnv("MP_TAGS_USERNAME") // Prometheus metrics if len(os.Getenv("MP_ENABLE_PROMETHEUS")) > 0 { diff --git a/config/config.go b/config/config.go index 3882e78..a033876 100644 --- a/config/config.go +++ b/config/config.go @@ -129,6 +129,9 @@ var ( // including x-tags & plus-addresses TagsDisable string + // TagsUsername enables auto-tagging messages with the authenticated username + TagsUsername bool + // SMTPRelayConfigFile to parse a yaml file and store config of the relay SMTP server SMTPRelayConfigFile string diff --git a/internal/pop3/pop3_test.go b/internal/pop3/pop3_test.go index 85d307b..df34186 100644 --- a/internal/pop3/pop3_test.go +++ b/internal/pop3/pop3_test.go @@ -346,7 +346,7 @@ func insertEmailData(t *testing.T) { bufBytes := buf.Bytes() - id, err := storage.Store(&bufBytes) + id, err := storage.Store(&bufBytes, nil) if err != nil { t.Log("error ", err) t.Fail() diff --git a/internal/smtpd/main.go b/internal/smtpd/main.go index ffd0679..ca61771 100644 --- a/internal/smtpd/main.go +++ b/internal/smtpd/main.go @@ -28,12 +28,12 @@ var ( ) // MailHandler handles the incoming message to store in the database -func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) { - return SaveToDatabase(origin, from, to, data) +func mailHandler(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) { + return SaveToDatabase(origin, from, to, data, smtpUser) } // SaveToDatabase will attempt to save a message to the database -func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (string, error) { +func SaveToDatabase(origin net.Addr, from string, to []string, data []byte, smtpUser *string) (string, error) { if !config.SMTPStrictRFCHeaders && bytes.Contains(data, []byte("\r\r\n")) { // replace all (\r\r\n) with (\r\n) // @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153 @@ -110,7 +110,7 @@ func SaveToDatabase(origin net.Addr, from string, to []string, data []byte) (str logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", ")) } - id, err := storage.Store(&data) + id, err := storage.Store(&data, smtpUser) if err != nil { logger.Log().Errorf("[db] error storing message: %s", err.Error()) return "", err diff --git a/internal/smtpd/smtpd.go b/internal/smtpd/smtpd.go index 0832920..e936f71 100644 --- a/internal/smtpd/smtpd.go +++ b/internal/smtpd/smtpd.go @@ -42,7 +42,7 @@ type Handler func(remoteAddr net.Addr, from string, to []string, data []byte) er // MsgIDHandler function called upon successful receipt of an email. Returns a message ID. // Results in a "250 2.0.0 Ok: queued as " response. -type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte) (string, error) +type MsgIDHandler func(remoteAddr net.Addr, from string, to []string, data []byte, username *string) (string, error) // HandlerRcpt function called on RCPT. Return accept status. type HandlerRcpt func(remoteAddr net.Addr, from string, to string) bool @@ -255,6 +255,7 @@ type session struct { xClientTrust bool // Trust XCLIENT from current IP address tls bool authenticated bool + username *string // username, nil if not authenticated } // Create new session from connection. @@ -550,7 +551,7 @@ loop: } s.writef("250 2.0.0 Ok: queued") } else if s.srv.MsgIDHandler != nil { - msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes()) + msgID, err := s.srv.MsgIDHandler(s.conn.RemoteAddr(), from, to, buffer.Bytes(), s.username) if err != nil { checkErrFormat := regexp.MustCompile(`^([2-5][0-9]{2})[\s\-](.+)$`) if checkErrFormat.MatchString(err.Error()) { @@ -917,6 +918,12 @@ func (s *session) handleAuthLogin(arg string) (bool, error) { // Validate credentials. authenticated, err := s.srv.AuthHandler(s.conn.RemoteAddr(), "LOGIN", username, password, nil) + if authenticated { + uname := string(username) + s.username = &uname + } else { + s.username = nil + } return authenticated, err } diff --git a/internal/storage/messages.go b/internal/storage/messages.go index 3a11089..8e636ce 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -25,8 +25,9 @@ import ( ) // Store will save an email to the database tables. +// The username is the authentication username of either the SMTP or HTTP client (blank for none). // Returns the database ID of the saved message. -func Store(body *[]byte) (string, error) { +func Store(body *[]byte, username *string) (string, error) { parser := enmime.NewParser(enmime.DisableCharacterDetection(true)) // Parse message body with enmime @@ -44,13 +45,16 @@ func Store(body *[]byte) (string, error) { from = &mail.Address{Name: env.GetHeader("From")} } - obj := DBMailSummary{ + obj := Metadata{ From: from, To: addressToSlice(env, "To"), Cc: addressToSlice(env, "Cc"), Bcc: addressToSlice(env, "Bcc"), ReplyTo: addressToSlice(env, "Reply-To"), } + if username != nil { + obj.Username = *username + } messageID := strings.Trim(env.GetHeader("Message-ID"), "<>") created := time.Now() @@ -91,8 +95,8 @@ func Store(body *[]byte) (string, error) { snippet := tools.CreateSnippet(env.Text, env.HTML) sql := fmt.Sprintf(`INSERT INTO %s - (Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) - VALUES(?,?,?,?,?,?,?,?,?,0,?)`, + (Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) + VALUES(?,?,?,?,?,?,?,?,?,0,?)`, tenant("mailbox"), ) // #nosec @@ -145,6 +149,11 @@ func Store(body *[]byte) (string, error) { tags = append(tags, obj.tagsFromPlusAddresses()...) } + // auto-tag by username if enabled + if config.TagsUsername && username != nil && *username != "" { + tags = append(tags, *username) + } + // extract tags from search matches, and sort and extract unique tags tags = sortedUniqueTags(append(tags, tagFilterMatches(id)...)) @@ -205,23 +214,32 @@ func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) { var id string var messageID string var subject string - var metadata string + var metadataJSON string var size float64 // use float64 for rqlite compatibility var attachments int var read int var snippet string em := MessageSummary{} + var meta Metadata - if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &snippet); err != nil { + err := row.Scan(&created, &id, &messageID, &subject, &metadataJSON, &size, &attachments, &read, &snippet) + if err != nil { logger.Log().Errorf("[db] %s", err.Error()) return } - if err := json.Unmarshal([]byte(metadata), &em); err != nil { + if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil { logger.Log().Errorf("[json] %s", err.Error()) return } + em.From = meta.From + em.To = meta.To + em.Cc = meta.Cc + em.Bcc = meta.Bcc + em.ReplyTo = meta.ReplyTo + em.Username = meta.Username + em.Created = time.UnixMilli(int64(created)) em.ID = id em.MessageID = messageID @@ -271,12 +289,20 @@ func GetMessage(id string) (*Message, error) { return nil, err } - var from *mail.Address - fromData := addressToSlice(env, "From") - if len(fromData) > 0 { - from = fromData[0] - } else if env.GetHeader("From") != "" { - from = &mail.Address{Name: env.GetHeader("From")} + // Load metadata from DB + meta, err := GetMetadata(id) + if err != nil { + meta = Metadata{} + } + + from := meta.From + if from == nil { + fromData := addressToSlice(env, "From") + if len(fromData) > 0 { + from = fromData[0] + } else if env.GetHeader("From") != "" { + from = &mail.Address{Name: env.GetHeader("From")} + } } messageID := strings.Trim(env.GetHeader("Message-ID"), "<>") @@ -302,7 +328,6 @@ func GetMessage(id string) (*Message, error) { } logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id) - date = time.UnixMilli(int64(created)) }); err != nil { logger.Log().Errorf("[db] %s", err.Error()) @@ -323,8 +348,8 @@ func GetMessage(id string) (*Message, error) { Tags: getMessageTags(id), Size: uint64(len(raw)), Text: env.Text, + Username: meta.Username, } - obj.HTML = env.HTML obj.Inline = []Attachment{} obj.Attachments = []Attachment{} @@ -747,3 +772,17 @@ func DeleteAllMessages() error { return err } + +// GetMetadata retrieves the metadata for a message by its ID +func GetMetadata(id string) (Metadata, error) { + var metadataJSON string + row := db.QueryRow(fmt.Sprintf("SELECT Metadata FROM %s WHERE ID = ?", tenant("mailbox")), id) + if err := row.Scan(&metadataJSON); err != nil { + return Metadata{}, err + } + var meta Metadata + if err := json.Unmarshal([]byte(metadataJSON), &meta); err != nil { + return Metadata{}, err + } + return meta, nil +} diff --git a/internal/storage/messages_test.go b/internal/storage/messages_test.go index 30bf108..cc52411 100644 --- a/internal/storage/messages_test.go +++ b/internal/storage/messages_test.go @@ -16,7 +16,7 @@ func TestTextEmailInserts(t *testing.T) { start := time.Now() for i := 0; i < testRuns; i++ { - if _, err := Store(&testTextEmail); err != nil { + if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } @@ -54,7 +54,7 @@ func TestMimeEmailInserts(t *testing.T) { start := time.Now() for i := 0; i < testRuns; i++ { - if _, err := Store(&testMimeEmail); err != nil { + if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } @@ -94,7 +94,7 @@ func TestRetrieveMimeEmail(t *testing.T) { t.Logf("Testing mime email retrieval (tenant %s)", tenantID) } - id, err := Store(&testMimeEmail) + id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() @@ -151,7 +151,7 @@ func TestMessageSummary(t *testing.T) { t.Logf("Testing message summary (tenant %s)", tenantID) } - if _, err := Store(&testMimeEmail); err != nil { + if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } @@ -185,7 +185,7 @@ func BenchmarkImportText(b *testing.B) { defer Close() for i := 0; i < b.N; i++ { - if _, err := Store(&testTextEmail); err != nil { + if _, err := Store(&testTextEmail, nil); err != nil { b.Log("error ", err) b.Fail() } @@ -197,7 +197,7 @@ func BenchmarkImportMime(b *testing.B) { defer Close() for i := 0; i < b.N; i++ { - if _, err := Store(&testMimeEmail); err != nil { + if _, err := Store(&testMimeEmail, nil); err != nil { b.Log("error ", err) b.Fail() } diff --git a/internal/storage/reindex.go b/internal/storage/reindex.go index 365ee61..cee8007 100644 --- a/internal/storage/reindex.go +++ b/internal/storage/reindex.go @@ -73,23 +73,22 @@ func ReindexAll() { continue } - from := &mail.Address{} + meta, _ := GetMetadata(id) + fromJSON := addressToSlice(env, "From") if len(fromJSON) > 0 { - from = fromJSON[0] + meta.From = fromJSON[0] } else if env.GetHeader("From") != "" { - from = &mail.Address{Name: env.GetHeader("From")} + meta.From = &mail.Address{Name: env.GetHeader("From")} + } else { + meta.From = nil } + meta.To = addressToSlice(env, "To") + meta.Cc = addressToSlice(env, "Cc") + meta.Bcc = addressToSlice(env, "Bcc") + meta.ReplyTo = addressToSlice(env, "Reply-To") - obj := DBMailSummary{ - From: from, - To: addressToSlice(env, "To"), - Cc: addressToSlice(env, "Cc"), - Bcc: addressToSlice(env, "Bcc"), - ReplyTo: addressToSlice(env, "Reply-To"), - } - - MetadataJSON, err := json.Marshal(obj) + MetadataJSON, err := json.Marshal(meta) if err != nil { logger.Log().Errorf("[message] %s", err.Error()) continue diff --git a/internal/storage/search_test.go b/internal/storage/search_test.go index 1c574a3..f139ed5 100644 --- a/internal/storage/search_test.go +++ b/internal/storage/search_test.go @@ -48,7 +48,7 @@ func TestSearch(t *testing.T) { bufBytes := buf.Bytes() - if _, err := Store(&bufBytes); err != nil { + if _, err := Store(&bufBytes, nil); err != nil { t.Log("error ", err) t.Fail() } @@ -117,11 +117,11 @@ func TestSearchDelete100(t *testing.T) { } for i := 0; i < 100; i++ { - if _, err := Store(&testTextEmail); err != nil { + if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } - if _, err := Store(&testMimeEmail); err != nil { + if _, err := Store(&testMimeEmail, nil); err != nil { t.Log("error ", err) t.Fail() } @@ -158,7 +158,7 @@ func TestSearchDelete1100(t *testing.T) { t.Log("Testing search delete of 1100 messages") for i := 0; i < 1100; i++ { - if _, err := Store(&testTextEmail); err != nil { + if _, err := Store(&testTextEmail, nil); err != nil { t.Log("error ", err) t.Fail() } diff --git a/internal/storage/structs.go b/internal/storage/structs.go index edde6fa..5bc521b 100644 --- a/internal/storage/structs.go +++ b/internal/storage/structs.go @@ -34,6 +34,8 @@ type Message struct { Date time.Time // Message tags Tags []string + // Username used for authentication (if provided) with the SMTP or Send API + Username string // Message body text Text string // Message body HTML @@ -86,6 +88,8 @@ type MessageSummary struct { Subject string // Received RFC3339Nano date & time ([extended RFC3339](https://tools.ietf.org/html/rfc3339#section-5.6) format with optional nano seconds) Created time.Time + // Username used for authentication (if provided) with the SMTP or Send API + Username string // Message tags Tags []string // Message size in bytes (total) @@ -103,13 +107,14 @@ type MailboxStats struct { Tags []string } -// DBMailSummary struct for storing mail summary -type DBMailSummary struct { - From *mail.Address - To []*mail.Address - Cc []*mail.Address - Bcc []*mail.Address - ReplyTo []*mail.Address +// Metadata struct for storing message metadata +type Metadata struct { + From *mail.Address `json:"From,omitempty"` + To []*mail.Address `json:"To,omitempty"` + Cc []*mail.Address `json:"Cc,omitempty"` + Bcc []*mail.Address `json:"Bcc,omitempty"` + ReplyTo []*mail.Address `json:"ReplyTo,omitempty"` + Username string `json:"Username,omitempty"` } // ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers diff --git a/internal/storage/tags.go b/internal/storage/tags.go index 6fff53a..0b13eff 100644 --- a/internal/storage/tags.go +++ b/internal/storage/tags.go @@ -323,7 +323,7 @@ func findTagsInRawMessage(message *[]byte) []string { } // Returns tags found in email plus addresses (eg: test+tagname@example.com) -func (d DBMailSummary) tagsFromPlusAddresses() []string { +func (d Metadata) tagsFromPlusAddresses() []string { tags := []string{} for _, c := range d.To { matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1) diff --git a/internal/storage/tags_test.go b/internal/storage/tags_test.go index 98548e5..e67d799 100644 --- a/internal/storage/tags_test.go +++ b/internal/storage/tags_test.go @@ -24,7 +24,7 @@ func TestTags(t *testing.T) { ids := []string{} for i := 0; i < 10; i++ { - id, err := Store(&testMimeEmail) + id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() @@ -57,7 +57,7 @@ func TestTags(t *testing.T) { } // test 20 tags - id, err := Store(&testMimeEmail) + id, err := Store(&testMimeEmail, nil) if err != nil { t.Log("error ", err) t.Fail() @@ -124,7 +124,7 @@ func TestTags(t *testing.T) { } // test 20 tags - id, err = Store(&testTagEmail) + id, err = Store(&testTagEmail, nil) if err != nil { t.Log("error ", err) t.Fail() @@ -141,3 +141,48 @@ func TestTags(t *testing.T) { } } +func TestUsernameAutoTagging(t *testing.T) { + setup("") + defer Close() + + username := "testuser" + + t.Run("Auto-tagging enabled", func(t *testing.T) { + config.TagsUsername = true + id, err := Store(&testTextEmail, &username) + if err != nil { + t.Fatalf("Store failed: %v", err) + } + msg, err := GetMessage(id) + if err != nil { + t.Fatalf("GetMessage failed: %v", err) + } + found := false + for _, tag := range msg.Tags { + if tag == username { + found = true + break + } + } + if !found { + t.Errorf("Expected username '%s' in tags, got %v", username, msg.Tags) + } + }) + + t.Run("Auto-tagging disabled", func(t *testing.T) { + config.TagsUsername = false + id, err := Store(&testTextEmail, &username) + if err != nil { + t.Fatalf("Store failed: %v", err) + } + msg, err := GetMessage(id) + if err != nil { + t.Fatalf("GetMessage failed: %v", err) + } + for _, tag := range msg.Tags { + if tag == username { + t.Errorf("Did not expect username '%s' in tags when disabled, got %v", username, msg.Tags) + } + } + }) +} diff --git a/server/apiv1/send.go b/server/apiv1/send.go index 9142add..f945b5a 100644 --- a/server/apiv1/send.go +++ b/server/apiv1/send.go @@ -175,7 +175,12 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) { return } - id, err := data.Send(r.RemoteAddr) + var httpAuthUser *string + if user, _, ok := r.BasicAuth(); ok { + httpAuthUser = &user + } + + id, err := data.Send(r.RemoteAddr, httpAuthUser) if err != nil { httpJSONError(w, err.Error()) @@ -190,7 +195,7 @@ func SendMessageHandler(w http.ResponseWriter, r *http.Request) { // Send will validate the message structure and attempt to send to Mailpit. // It returns a sending summary or an error. -func (d SendRequest) Send(remoteAddr string) (string, error) { +func (d SendRequest) Send(remoteAddr string, httpAuthUser *string) (string, error) { ip, _, err := net.SplitHostPort(remoteAddr) if err != nil { return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error()) @@ -302,5 +307,5 @@ func (d SendRequest) Send(remoteAddr string) (string, error) { return "", fmt.Errorf("error building message: %s", err.Error()) } - return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes()) + return smtpd.SaveToDatabase(ipAddr, d.From.Email, addresses, buff.Bytes(), httpAuthUser) } diff --git a/server/server_test.go b/server/server_test.go index 75958db..f8d3ec8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -552,7 +552,7 @@ func insertEmailData(t *testing.T) { bufBytes := buf.Bytes() - id, err := storage.Store(&bufBytes) + id, err := storage.Store(&bufBytes, nil) if err != nil { t.Log("error ", err) t.Fail() diff --git a/server/ui-src/components/message/Message.vue b/server/ui-src/components/message/Message.vue index 0ec8319..856b09f 100644 --- a/server/ui-src/components/message/Message.vue +++ b/server/ui-src/components/message/Message.vue @@ -323,8 +323,9 @@ export default { From - {{ message.From.Name + " " - }} + + {{ message.From.Name + " " }} + < {{ message.From.Address }} @@ -418,6 +419,18 @@ export default { ({{ getFileSize(message.Size) }}) + + + Username + + + + + {{ message.Username }} + + Tags diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json index a440147..c370deb 100644 --- a/server/ui/api/v1/swagger.json +++ b/server/ui/api/v1/swagger.json @@ -1554,6 +1554,10 @@ "items": { "$ref": "#/definitions/Address" } + }, + "Username": { + "description": "Username used for authentication (if provided) with the SMTP or Send API", + "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/storage" @@ -1646,6 +1650,10 @@ "items": { "$ref": "#/definitions/Address" } + }, + "Username": { + "description": "Username used for authentication (if provided) with the SMTP or Send API", + "type": "string" } }, "x-go-package": "github.com/axllent/mailpit/internal/storage"