// Package mail provides functions for interacting with the mail server system. // This file handles reading system logs from systemd's journal using journalctl. package mail import ( "bufio" // buffered scanner for efficient line-by-line reading "bytes" // buffer management for capturing command output "context" // context for cancellation and timeouts "os/exec" // executing external commands (journalctl) "regexp" // regular expressions for parsing log entries "strings" // string manipulation "time" // time formatting and duration calculations ) // LogEntry represents a single log line from the system journal. type LogEntry struct { Timestamp string `json:"timestamp"` // When the log entry was created (e.g., "2024-01-15 10:30:45") Priority string `json:"priority"` // Log level: "info", "warning", "err", etc. Message string `json:"message"` // The actual log message content } // GetLogs retrieves Postfix mail log entries from systemd journal. // hours: how many hours of logs to look back (e.g., 24 = last 24 hours) // filter: optional text filter - only returns lines containing this text (case-insensitive) // Returns a slice of LogEntry structs sorted from newest to oldest. func GetLogs(hours int, filter string) ([]LogEntry, error) { // Create a context with a 30-second timeout. // This prevents the journalctl command from hanging forever if there's an issue. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Always call cancel to clean up resources // Build the journalctl command arguments. // -u: filter by systemd unit name (postfix in this case) // --since: start time (formatted as "YYYY-MM-DD HH:MM:SS") // --no-pager: don't use a pager, just output everything directly args := []string{ "-u", "postfix", "--since", formatDuration(hours), "--no-pager", } // Execute journalctl with our arguments. // journalctl reads from systemd journal, not plain text files. cmd := exec.CommandContext(ctx, "/usr/bin/journalctl", args...) var out bytes.Buffer cmd.Stdout = &out // Capture output to our buffer if err := cmd.Run(); err != nil { // journalctl returns non-zero when there are no entries. // That's not really an error for us, so we could handle it, // but for simplicity we just pass the error through. return nil, err } // Parse the journalctl output line by line. var entries []LogEntry scanner := bufio.NewScanner(&out) // Regular expression to extract priority/level from log entries. // systemd logs look like: "[priority] some message" priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip empty lines and "No entries" messages. // journalctl returns "-- No entries --" when there's nothing to show. if strings.Contains(line, "-- No entries") || line == "" { continue } // Apply text filter if provided. // Case-insensitive search using ToLower on both strings. if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) { continue } // journalctl outputs lines in this format: // Jan 15 10:30:45 hostname postfix/cleanup[12345]: [priority] message // We split by spaces, but since the message can contain spaces, // we use SplitN with limit 4 to get: timestamp, hostname, process, message parts := strings.SplitN(line, " ", 4) if len(parts) < 4 { continue // Skip malformed lines } // Combine first two parts for full timestamp: "Jan 15 10:30:45" timestamp := parts[0] + " " + parts[1] // The third part contains the process info like "postfix/cleanup[12345]:" // We extract the priority level from this part. matches := priorityRegex.FindStringSubmatch(parts[2]) priority := "info" // default priority if len(matches) > 1 { // Found a priority in brackets like [info], [warning], [error] priority = matches[1] } else if strings.Contains(parts[2], "err") { // Some log lines don't use brackets, so we check for keywords priority = "err" } else if strings.Contains(parts[2], "warning") || strings.Contains(parts[2], "warn") { priority = "warning" } // The fourth part is the actual log message. message := parts[3] // Double-check the filter applies to the message part specifically. // This is optional since we already filtered above, but provides extra safety. if filter != "" && !strings.Contains(strings.ToLower(message), strings.ToLower(filter)) { continue } // Create a LogEntry and add it to our results. entries = append(entries, LogEntry{ Timestamp: timestamp, Priority: priority, Message: message, }) } // Return empty slice instead of nil for consistent JSON serialization. if entries == nil { entries = []LogEntry{} } return entries, nil } // formatDuration converts hours into a journalctl-compatible timestamp. // journalctl expects timestamps in format: "YYYY-MM-DD HH:MM:SS" // We calculate the timestamp by subtracting the requested hours from now. func formatDuration(hours int) string { return time.Now().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05") }