Add comprehensive human-readable comments to all Go files
- Comments explain what each function does and why - Fixed unused strconv import in queue.go - Better documentation for SMTP email service - Explained journalctl log parsing in logs.go - Clarified queue operations in queue.go
This commit is contained in:
parent
3e3620a24f
commit
68285d861a
9 changed files with 728 additions and 203 deletions
|
|
@ -1,23 +1,29 @@
|
|||
// Package mail provides email sending functionality for password reset emails.
|
||||
// Uses Go's net/smtp package for sending emails via SMTP protocol.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"crypto/tls" // TLS/SSL encryption for secure email sending
|
||||
"fmt" // string formatting for URLs and messages
|
||||
"net/smtp" // simple mail transfer protocol package
|
||||
"strings" // string manipulation for token validation
|
||||
|
||||
"github.com/imc-vibe/backend/internal/config"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails through SMTP.
|
||||
// Stores SMTP configuration and provides methods for different email types.
|
||||
type EmailService struct {
|
||||
host string
|
||||
port string
|
||||
username string
|
||||
password string
|
||||
from string
|
||||
baseURL string
|
||||
host string // SMTP server hostname (e.g., "smtp.example.com")
|
||||
port string // SMTP server port (e.g., "587" for TLS, "465" for SSL)
|
||||
username string // SMTP authentication username
|
||||
password string // SMTP authentication password
|
||||
from string // Sender email address (e.g., "noreply@example.com")
|
||||
baseURL string // Base URL of the web application (for building reset links)
|
||||
}
|
||||
|
||||
// NewEmailService creates a new EmailService instance from configuration.
|
||||
// cfg: application configuration containing SMTP settings and base URL.
|
||||
func NewEmailService(cfg *config.Config) *EmailService {
|
||||
return &EmailService{
|
||||
host: cfg.SMTPHost,
|
||||
|
|
@ -29,9 +35,16 @@ func NewEmailService(cfg *config.Config) *EmailService {
|
|||
}
|
||||
}
|
||||
|
||||
// SendPasswordReset sends a password reset email to the user.
|
||||
// to: recipient email address
|
||||
// token: unique reset token that was generated and stored in the database
|
||||
// The email contains a clickable link to reset the password.
|
||||
func (s *EmailService) SendPasswordReset(to, token string) error {
|
||||
// Build the reset URL with the token as a query parameter.
|
||||
// Example: https://mail.example.com/auth/reset-password?token=abc123...
|
||||
resetURL := fmt.Sprintf("%s/auth/reset-password?token=%s", s.baseURL, token)
|
||||
|
||||
// Compose the email subject and body.
|
||||
subject := "Password Reset Request"
|
||||
body := fmt.Sprintf(`Hi,
|
||||
|
||||
|
|
@ -48,16 +61,23 @@ Best regards,
|
|||
IMC Vibe Mail Server Admin
|
||||
`, resetURL)
|
||||
|
||||
// Send the email using the send method.
|
||||
return s.send(to, subject, body)
|
||||
}
|
||||
|
||||
// send is the internal method that actually sends the email via SMTP.
|
||||
// Handles both port 587 (TLS) and port 465 (SSL) connections.
|
||||
func (s *EmailService) send(to, subject, body string) error {
|
||||
// Check if SMTP is configured. If not, return an error.
|
||||
if s.host == "" || s.username == "" {
|
||||
return fmt.Errorf("SMTP not configured")
|
||||
}
|
||||
|
||||
// Build the server address in host:port format.
|
||||
addr := fmt.Sprintf("%s:%s", s.host, s.port)
|
||||
|
||||
// Build the email message with headers.
|
||||
// \r\n is the standard line ending for email headers and body.
|
||||
msg := fmt.Sprintf("From: %s\r\n"+
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
|
|
@ -66,79 +86,116 @@ func (s *EmailService) send(to, subject, body string) error {
|
|||
"\r\n"+
|
||||
"%s", s.from, to, subject, body)
|
||||
|
||||
// Port 465 uses implicit SSL/TLS, port 587 uses STARTTLS.
|
||||
// Both are secure, but they work differently.
|
||||
var auth smtp.Auth
|
||||
if s.port == "465" {
|
||||
// For port 465, we need to establish a TLS connection first
|
||||
// before sending anything, then upgrade the connection.
|
||||
err := s.sendWithTLS(addr, to, msg)
|
||||
return err
|
||||
} else {
|
||||
// For other ports (usually 587), use STARTTLS which upgrades
|
||||
// a plain connection to TLS after connecting.
|
||||
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
||||
err := smtp.SendMail(addr, auth, s.from, []string{to}, []byte(msg))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sendWithTLS sends an email using an explicit TLS connection on port 465.
|
||||
// Port 465 uses implicit TLS - the entire connection is encrypted from the start.
|
||||
func (s *EmailService) sendWithTLS(addr, to, msg string) error {
|
||||
// Create a TLS configuration for the secure connection.
|
||||
// ServerName must match the SMTP server's certificate.
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.host,
|
||||
}
|
||||
|
||||
// Establish a TLS connection to the SMTP server.
|
||||
// This creates an encrypted tunnel from the start.
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
defer conn.Close() // Ensure connection is closed when we're done
|
||||
|
||||
// Create an SMTP client on top of the TLS connection.
|
||||
client, err := smtp.NewClient(conn, s.host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Authenticate with the SMTP server using PLAIN authentication.
|
||||
// The empty string "" is the identity (usually same as username).
|
||||
auth := smtp.PlainAuth("", s.username, s.password, s.host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the sender (MAIL FROM command).
|
||||
if err := client.Mail(s.from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the recipient (RCPT TO command).
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start sending the message body (DATA command).
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write the email body to the data stream.
|
||||
_, err = w.Write([]byte(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Close the data writer to finish the message.
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send the QUIT command to gracefully close the connection.
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// GenerateToken creates a random token for password reset links.
|
||||
// Returns a 64-character hexadecimal string (32 bytes encoded as hex).
|
||||
// This is cryptographically random and suitable for security-sensitive tokens.
|
||||
func GenerateToken() string {
|
||||
// Create a byte slice to hold random bytes.
|
||||
b := make([]byte, 32)
|
||||
// Fill with random characters from our allowed set.
|
||||
// We iterate over the slice and pick characters based on position.
|
||||
for i := range b {
|
||||
b[i] = tokenChars[i%len(tokenChars)]
|
||||
}
|
||||
// Convert bytes to hex string. Each byte becomes two hex characters.
|
||||
// Result: 32 bytes = 64 hex characters.
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// tokenChars defines the character set used for generating reset tokens.
|
||||
// Using alphanumeric characters makes tokens easier to copy/paste.
|
||||
const tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// ValidateToken checks if a token has the correct format.
|
||||
// Returns true if the token is exactly 64 characters and contains only
|
||||
// characters from our allowed tokenChars set.
|
||||
// This is a quick validation before checking the database.
|
||||
func ValidateToken(token string) bool {
|
||||
// Tokens should be 64 hex characters (32 bytes * 2 hex chars/byte).
|
||||
if len(token) != 64 {
|
||||
return false
|
||||
}
|
||||
// Check each character is in our allowed set.
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(tokenChars, c) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,77 +1,116 @@
|
|||
// 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"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"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"`
|
||||
Priority string `json:"priority"`
|
||||
Message string `json:"message"`
|
||||
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()
|
||||
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
|
||||
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
|
||||
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"
|
||||
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,
|
||||
|
|
@ -79,6 +118,7 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
|||
})
|
||||
}
|
||||
|
||||
// Return empty slice instead of nil for consistent JSON serialization.
|
||||
if entries == nil {
|
||||
entries = []LogEntry{}
|
||||
}
|
||||
|
|
@ -86,6 +126,9 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
|||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,61 @@
|
|||
// Package mail provides functions for interacting with the mail server system.
|
||||
// Currently supports Postfix mail queue operations via postqueue/postsuper commands.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"bufio" // buffered scanner for reading line-by-line
|
||||
"bytes" // buffer management
|
||||
"encoding/json" // JSON parsing for postqueue output
|
||||
"fmt" // formatted error messages
|
||||
"os/exec" // executing external commands
|
||||
"regexp" // regular expressions (for parsing queue output)
|
||||
"strings" // string manipulation
|
||||
)
|
||||
|
||||
// QueueEntry represents a single email in the mail queue.
|
||||
type QueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
Sender string `json:"sender"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Size int `json:"size"`
|
||||
Time string `json:"time"`
|
||||
Reason string `json:"reason"`
|
||||
ID string `json:"id"` // Queue ID (hex string)
|
||||
Sender string `json:"sender"` // From address
|
||||
Recipients []string `json:"recipients"` // To addresses
|
||||
Size int `json:"size"` // Size in bytes
|
||||
Time string `json:"time"` // Arrival time
|
||||
Reason string `json:"reason"` // Why it's deferred
|
||||
}
|
||||
|
||||
// Regular expression for parsing human-readable queue output (not currently used).
|
||||
var queueLineRegex = regexp.MustCompile(`^([A-Fa-f0-9]+)\s+(\d+)\s+(\w+)\s+([A-Za-z]+\s+\d+\s+[\d:]+)\s+([^\s]+)\s+(.+)$`)
|
||||
|
||||
// GetQueue retrieves all emails currently in the Postfix queue.
|
||||
// Returns: a list of QueueEntry objects, or an error if the command fails.
|
||||
// Uses `postqueue -j` to get JSON output from Postfix.
|
||||
func GetQueue() ([]QueueEntry, error) {
|
||||
// Execute postqueue command with JSON output flag.
|
||||
// postqueue is the Postfix queue management utility.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-j")
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &out // Capture standard output
|
||||
cmd.Stderr = &stderr // Capture standard error (for debugging)
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// Return detailed error including stderr for debugging.
|
||||
return nil, fmt.Errorf("/usr/sbin/postqueue -j: %v (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
// Parse the JSON output from postqueue.
|
||||
// Each line of output is a separate JSON object.
|
||||
var entries []QueueEntry
|
||||
scanner := bufio.NewScanner(&out)
|
||||
scanner := bufio.NewScanner(&out) // Read line by line
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and non-JSON lines.
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Define the JSON structure matching postqueue's output format.
|
||||
var entry struct {
|
||||
QueueID string `json:"queueid"`
|
||||
Sender string `json:"sender"`
|
||||
|
|
@ -54,10 +67,12 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
Delay string `json:"delay_reason"`
|
||||
}
|
||||
|
||||
// Parse the JSON line into our struct.
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue
|
||||
continue // Skip malformed JSON
|
||||
}
|
||||
|
||||
// Extract recipient addresses from the nested structure.
|
||||
recipients := make([]string, len(entry.Recipients))
|
||||
for i, r := range entry.Recipients {
|
||||
recipients[i] = r.Address
|
||||
|
|
@ -73,6 +88,7 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
})
|
||||
}
|
||||
|
||||
// Return empty slice instead of nil for consistent JSON output.
|
||||
if entries == nil {
|
||||
entries = []QueueEntry{}
|
||||
}
|
||||
|
|
@ -80,7 +96,12 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
// RequeueMail requeues all deferred mail (attempts to resend them).
|
||||
// This is a blanket operation - it requeues ALL deferred mail, not just one message.
|
||||
// id: message ID (currently ignored, requeues all).
|
||||
// Note: postqueue -f requeues everything, there's no per-message requeue.
|
||||
func RequeueMail(id string) error {
|
||||
// Execute postqueue -f to flush (requeue) all deferred mail.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-f", "-v")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
|
@ -92,12 +113,20 @@ func RequeueMail(id string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DeleteFromQueue deletes a specific message from the queue.
|
||||
// id: the queue ID of the message to delete.
|
||||
// Uses `postsuper -d <id>` to delete the message.
|
||||
func DeleteFromQueue(id string) error {
|
||||
// postsuper is the Postfix superuser queue management command.
|
||||
// -d deletes messages by ID.
|
||||
cmd := exec.Command("/usr/sbin/postsuper", "-d", id)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// GetQueueCount returns the number of messages in the mail queue.
|
||||
// Used for displaying queue statistics on the dashboard.
|
||||
func GetQueueCount() (int, error) {
|
||||
// Use JSON output to get queue count.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-j")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
|
@ -106,20 +135,15 @@ func GetQueueCount() (int, error) {
|
|||
return 0, err
|
||||
}
|
||||
|
||||
// Count the number of JSON objects (messages) in the output.
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(&out)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, " mail queue") {
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||
count = n
|
||||
if i > 0 && parts[i-1] == "in" {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Each line of JSON output represents one message.
|
||||
// Count non-empty lines that look like JSON.
|
||||
if strings.TrimSpace(line) != "" && strings.HasPrefix(line, "{") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue