Feature: Store username with messages, auto-tag, and UI display (#521)

This commit is contained in:
Ralph Slooten
2025-06-18 16:41:04 +12:00
parent d4ee6fd987
commit 4b5ce0afed
16 changed files with 187 additions and 61 deletions

View File

@@ -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|<ip:port> (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 {

View File

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

View File

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

View File

@@ -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 <CR><CR><LF> (\r\r\n) with <CR><LF> (\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

View File

@@ -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 <message-id>" 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -323,8 +323,9 @@ export default {
<th class="small">From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " "
}}</span>
<span v-if="message.From.Name" class="text-spaces">
{{ message.From.Name + " " }}
</span>
<span v-if="message.From.Address" class="small">
&lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }}
@@ -418,6 +419,18 @@ export default {
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
</td>
</tr>
<tr v-if="message.Username" class="small">
<th class="small">
Username
<i class="bi bi-exclamation-circle ms-1" data-bs-toggle="tooltip"
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
data-bs-title="The SMTP or send API username the client authenticated with">
</i>
</th>
<td class="small">
{{ message.Username }}
</td>
</tr>
<tr class="small">
<th>Tags</th>
<td>

View File

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