package main import ( "bytes" "fmt" "io" "io/ioutil" "log" "os" "path" "regexp" "strconv" "strings" "time" "github.com/DusanKasan/parsemail" "github.com/google/uuid" ) // When a new message is received, this function is called to store it. func MailSaveMessage(remoteAddr string, from string, to string, r io.Reader) error { // We need the message body in bytes to save. b, err := ioutil.ReadAll(r) if err != nil { // If we can't read, we have an issue. return err } // The email parser expects an io.Reader, but as we already read the reader passed. We must make a new one. reader := bytes.NewReader(b) // Parse the email email, err := parsemail.Parse(reader) if err != nil { return err } // Generate a UUID for this message. UUID := uuid.New().String() // Save message body using database or file if configured. if app.config.MailPath == "db" { // Save to database. message := Messages{} message.UUID = UUID message.Message = b app.db.Create(&message) } else { // If the directory configured in the config does not exist... We must fail. if _, err := os.Stat(app.config.MailPath); err != nil { return fmt.Errorf("Mail directory does not exist: %s", app.config.MailPath) } // Create the file. messagePath := path.Join(app.config.MailPath, UUID) fp, err := os.Create(messagePath) if err != nil { return err } // Write to the file fp.Write(b) fp.Close() } // Create a message log entry with parsed email. messageEntry := MessageLog{} messageEntry.UUID = UUID messageEntry.MessageID = email.MessageID if len(email.From) <= 0 { messageEntry.From = from } else { messageEntry.From = email.From[0].Address } if len(email.To) <= 0 { messageEntry.To = to } else { messageEntry.To = email.To[0].Address } messageEntry.Subject = email.Subject messageEntry.PlainText = email.TextBody != "" messageEntry.HTML = email.HTMLBody != "" messageEntry.Attachments = len(email.Attachments) > 0 // If a spam level header exists, parse the score. spamScore := email.Header.Get("X-SPAM-LEVEL") rxScore := regexp.MustCompile("Spam detection results:\\s+([0-9]+)") matches := rxScore.FindStringSubmatch(spamScore) if len(matches) == 2 { spamScoreI, err := strconv.Atoi(matches[1]) if err != nil { spamScoreI = 0 } messageEntry.SpamScore = spamScoreI } // Get the source IP from the earliest received header. Default to remote address which is sending the message. // As messages are likely forwarded to this server, the earliest received header is what we want here. messageEntry.SourceIP = remoteAddr // Regex to parse the received header with hostname/ip address. rxAddr := regexp.MustCompile("from ([A-Za-z0-9-.]+) \\(.* \\[([0-9a-fA-F.:]+)\\]\\)") // Loop through all entires of received headers. for _, header := range email.Header["Received"] { // Parse the header. matches := rxAddr.FindStringSubmatch(header) if len(matches) == 3 { // If we got an source IP from the header, save it. messageEntry.SourceIP = matches[1] + " (" + matches[2] + ")" } } messageEntry.Size = len(b) messageEntry.Received = time.Now() messageEntry.Status = "unknown" // We start as unknown and the status is updated by syslog. // Save the message entry. app.db.Create(&messageEntry) log.Printf("SMTP: Received message from %s (%d bytes)", messageEntry.From, messageEntry.Size) // Notify websocket subscribers of new message. app.httpServer.wsInterface.sendMessage("receivedNewMessage", messageEntry) // Update message count. app.messageCount++ app.httpServer.wsInterface.sendMessage("updateMessageCount", app.messageCount) return nil } // Finds and outputs a reader for the message body based on UUID. func MailGetMessageData(UUID string) (r io.Reader, err error) { // If we are configured to use the database for storage, then we should check if the UUID is in the database. // Otherwise, we check the path set to see if a file exists with the UUID. if app.config.MailPath == "db" { // Search database for message body by UUID. var message Messages app.db.Where("uuid = ?", UUID).First(&message) // If not found, we provide an error. if message.UUID == "" { err = fmt.Errorf(APINoMessage) return } // Create a reader for the message data. r = bytes.NewReader(message.Message) } else { // Verify that the UUID exists in the file system. if _, err = os.Stat(path.Join(app.config.MailPath, UUID)); err != nil { return } // If the file exists, we open it to return. r, err = os.Open(path.Join(app.config.MailPath, UUID)) } // Return reader. return } // To try and make the syslog code light weight, this function was created // to update the status of messages to what was parsed in the syslog. // This function will read an update queue map of syslog ids with updated statuses. func RunSysLogMailUpdateQueue() { ticker := time.NewTicker(5 * time.Second) for _ = range ticker.C { // Every 5 seconds. // We want to keep track as to rather statuses were updated to notify subscribers. updated := false // Copy the update queue so that we can empty the main update queue. updateQueue := make(map[string]bool) for key, val := range app.sysLogMailUpdateQueue { updateQueue[key] = val } // We empty the main update queue as the syslog may have status changes during this run. // We want to ensure that those changes do not get lost. app.sysLogMailUpdateQueue = make(map[string]bool) // Loop the update queue. for sidHostname, _ := range updateQueue { // Update queue should contain syslog id and hostname separated by a colon. // We pair the syslog id with the hostname just incase different hosts use the same syslog id. s := strings.Split(sidHostname, ":") // If there is more or less than 2 parts, this is invalid. if len(s) != 2 { continue } sid := s[0] hostname := s[1] // Pull the syslog id information from the database. var match SysLogIDInfo app.db.Where("s_id = ? AND hostname = ?", sid, hostname).First(&match) // If nothing was returned, or this one is set to be ignored... We will stop here. // When a syslog id is ignored, it is likely due to it being either the main message received before sending out, // or it is the message forwarded to Mail Archive which is not the main mail delivery status. if match.SID == "" || match.Ignore { continue } // Pull the message log entry matching the message id associated with the syslog id. var messageEntry MessageLog app.db.Where("message_id = ?", match.MessageID).First(&messageEntry) // If we found the message log entry, we can update the status to match our syslog id delivery status. if messageEntry.UUID != "" { messageEntry.Status = match.Status app.db.Save(&messageEntry) // As we updated a message log entry, we want to inform the subscribers that an update occurred. updated = true } } // If we updated the status of a message log entry, we need to inform subscribers connected to websocket. if updated { app.httpServer.wsInterface.sendMessage("messageStatusesUpdated", true) } } } // This function will run a database cleanup of old messages every 30 minutes. func RunDatabaseCleanup() { ticker := time.NewTicker(30 * time.Minute) for _ = range ticker.C { // Get the oldest date we will allow at this point in time based on the configured maximum age. maxAge := time.Now().Add(app.config.MaxAge * time.Second * -1) // We want to just pull UUID and message id of the old messages to be cleaned up. type MessageIDs struct { UUID string MessageID string } var messageIDs []MessageIDs app.db.Table("message_logs").Select("uuid,message_id").Where("received <= ?", maxAge).Scan(&messageIDs) // Loop through all found old messages to clean up the database. for _, message := range messageIDs { // Find syslog id information entries matching this message. var matches []SysLogIDInfo app.db.Where("message_id = ?", message.MessageID).Find(&matches) // With each found syslog id, we need to delete the syslog messages and the syslog id information. for _, match := range matches { app.db.Where("s_id = ? AND hostname = ?", match.SID, match.Hostname).Delete(SysLogMessage{}) app.db.Delete(&match) } // Delete the message log entry for this message. app.db.Where("uuid = ?", message.UUID).Delete(MessageLog{}) // Delete message data matching the UUID for the message. app.db.Where("uuid = ?", message.UUID).Delete(Messages{}) // If the configured mail storage path is not the database, remove it from the file system. if app.config.MailPath != "db" { if _, err := os.Stat(path.Join(app.config.MailPath, message.UUID)); err == nil { os.Remove(path.Join(app.config.MailPath, message.UUID)) } } // Update message count. app.messageCount-- } // Send updated message count. app.httpServer.wsInterface.sendMessage("updateMessageCount", app.messageCount) } }