Email archive server with SysLog support and web interface.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

252 lines
8.8 KiB

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