package main import ( "bufio" "crypto/sha256" "fmt" "io/ioutil" "log" "math/rand" "net" "net/mail" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "syscall" "time" "github.com/spf13/viper" "golang.org/x/net/proxy" // Imports for encoding handling "golang.org/x/text/encoding" "golang.org/x/text/encoding/charmap" "golang.org/x/text/encoding/htmlindex" "golang.org/x/text/transform" // Imports for transfer encoding "mime/quotedprintable" "encoding/base64" ) // Config structure to hold all configuration type Config struct { Paths PathsConfig `mapstructure:"paths"` NNTP NNTPConfig `mapstructure:"nntp"` Thresholds ThresholdsConfig `mapstructure:"thresholds"` Logging LoggingConfig `mapstructure:"logging"` Encoding EncodingConfig `mapstructure:"encoding"` } type PathsConfig struct { Log string `mapstructure:"log"` Etc string `mapstructure:"etc"` Lib string `mapstructure:"lib"` History string `mapstructure:"history"` } type NNTPConfig struct { Path string `mapstructure:"path_header"` InjectionHost string `mapstructure:"injection_host"` Contact string `mapstructure:"contact"` MessageID string `mapstructure:"messageid"` DefaultFrom string `mapstructure:"default_from"` PrimaryOnion string `mapstructure:"primary_onion"` FallbackServer string `mapstructure:"fallback_server"` TorProxy string `mapstructure:"tor_proxy"` AlwaysUseTor bool `mapstructure:"always_use_tor"` } type ThresholdsConfig struct { MaxBytes int `mapstructure:"max_bytes"` MaxCrossposts int `mapstructure:"max_crossposts"` HoursPast int `mapstructure:"hours_past"` HoursFuture int `mapstructure:"hours_future"` SocketTimeout int `mapstructure:"socket_timeout"` } type LoggingConfig struct { Level string `mapstructure:"level"` Format string `mapstructure:"format"` DateFmt string `mapstructure:"datefmt"` Retain int `mapstructure:"retain"` } type EncodingConfig struct { ForceUtf8 bool `mapstructure:"force_utf8"` FallbackCharset string `mapstructure:"fallback_charset"` } // Global configuration var config Config // Version constant const VERSION = "0.9.4" // Global lock file path and handle var lockFilePath string var lockFile *os.File // MessageIDCache stores recently processed message IDs to avoid duplicates type MessageIDCache struct { cache map[string]time.Time // Maps message ID to time it was processed mutex sync.RWMutex maxAge time.Duration // Maximum age of cache entries cacheDir string // Directory for persistent cache } // Global message ID cache var messageCache *MessageIDCache // NewMessageIDCache creates a new message ID cache with the specified max age func NewMessageIDCache(maxAge time.Duration, cacheDir string) *MessageIDCache { // Create cache directory if it doesn't exist if cacheDir != "" { os.MkdirAll(cacheDir, 0755) } cache := &MessageIDCache{ cache: make(map[string]time.Time), maxAge: maxAge, cacheDir: cacheDir, } // Load persisted cache (if exists) cache.loadCache() // Start background cleanup goroutine go cache.startCleanupLoop(maxAge / 2) return cache } // loadCache loads the persisted cache from disk func (c *MessageIDCache) loadCache() { if c.cacheDir == "" { return } cacheFile := filepath.Join(c.cacheDir, "message_cache.txt") data, err := ioutil.ReadFile(cacheFile) if err != nil { // Cache file may not exist yet, that's ok return } c.mutex.Lock() defer c.mutex.Unlock() // Clear existing cache c.cache = make(map[string]time.Time) // Parse each line as "messageID timestamp" lines := strings.Split(string(data), "\n") now := time.Now() for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, " ") if len(parts) != 2 { continue } messageID := parts[0] timestampStr := parts[1] timestamp, err := strconv.ParseInt(timestampStr, 10, 64) if err != nil { continue } processTime := time.Unix(timestamp, 0) // Only load entries that haven't expired if now.Sub(processTime) < c.maxAge { c.cache[messageID] = processTime } } logMessage(fmt.Sprintf("Loaded %d recent message IDs from cache", len(c.cache)), "DEBUG") } // saveCache persists the cache to disk func (c *MessageIDCache) saveCache() { if c.cacheDir == "" { return } c.mutex.RLock() defer c.mutex.RUnlock() // Build cache file content var cacheContent strings.Builder for messageID, timestamp := range c.cache { cacheContent.WriteString(fmt.Sprintf("%s %d\n", messageID, timestamp.Unix())) } // Write to file cacheFile := filepath.Join(c.cacheDir, "message_cache.txt") err := ioutil.WriteFile(cacheFile, []byte(cacheContent.String()), 0644) if err != nil { logMessage(fmt.Sprintf("Error saving message cache: %v", err), "WARNING") } } // startCleanupLoop periodically cleans up expired cache entries func (c *MessageIDCache) startCleanupLoop(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { c.cleanup() c.saveCache() } } // cleanup removes expired entries from the cache func (c *MessageIDCache) cleanup() { c.mutex.Lock() defer c.mutex.Unlock() now := time.Now() for id, timestamp := range c.cache { if now.Sub(timestamp) > c.maxAge { delete(c.cache, id) } } } // IsProcessed checks if a message ID has been processed recently func (c *MessageIDCache) IsProcessed(messageID string) bool { // Strip any angle brackets from message ID messageID = strings.Trim(messageID, "<>") c.mutex.RLock() defer c.mutex.RUnlock() _, exists := c.cache[messageID] return exists } // MarkProcessed marks a message ID as processed func (c *MessageIDCache) MarkProcessed(messageID string) { // Strip any angle brackets from message ID messageID = strings.Trim(messageID, "<>") c.mutex.Lock() defer c.mutex.Unlock() c.cache[messageID] = time.Now() // If cache is getting large, trigger a save if len(c.cache)%100 == 0 { go c.saveCache() } } // acquireLock creates a lock file to ensure only one instance is running // Using both atomic file creation with O_EXCL and system-level flock func acquireLock() bool { // Use a fixed location for better lock handling lockDir := "/var/lock/mail2news" err := os.MkdirAll(lockDir, 0755) if err != nil { log.Printf("Error creating lock directory %s: %v", lockDir, err) // Fall back to temp directory if can't create lock dir lockDir = os.TempDir() } lockFilePath = filepath.Join(lockDir, "mail2news.lock") // Attempt to create the lock file with O_EXCL flag file, err := os.OpenFile(lockFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { // If file exists, check if it's stale if os.IsExist(err) { log.Printf("Lock file exists at %s. Checking if it's stale...", lockFilePath) // Try to acquire the existing lock for reading existingFile, err := os.OpenFile(lockFilePath, os.O_RDWR, 0600) if err != nil { log.Printf("Error opening existing lock file: %v", err) return false } // Try to apply a non-blocking flock - if successful, the lock is stale err = syscall.Flock(int(existingFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err != nil { // Cannot lock - another process has it existingFile.Close() log.Printf("Another mail2news instance is holding the lock. Exiting.") return false } // We got the lock, so it was stale. Read PID from file for logging content, _ := ioutil.ReadAll(existingFile) lines := strings.Split(string(content), "\n") if len(lines) > 0 { pidStr := strings.TrimSpace(lines[0]) pid, _ := strconv.Atoi(pidStr) log.Printf("Found stale lock from PID %d. Removing it.", pid) } else { log.Printf("Found stale lock file with invalid format. Removing it.") } // Keep this file and handle since we've already flocked it lockFile = existingFile // Truncate and rewrite with our info lockFile.Truncate(0) lockFile.Seek(0, 0) // Write our PID and info to the lock file pid := os.Getpid() timestamp := time.Now().Format(time.RFC3339) lockContent := fmt.Sprintf("%d\n%s\nmail2news v%s", pid, timestamp, VERSION) lockFile.WriteString(lockContent) log.Printf("Lock acquired with flock by PID %d (recycled stale lock)", pid) return true } // Any other error creating the lock file log.Printf("Failed to create lock file: %v", err) return false } // Keep reference to the file so we can flock it and close it later lockFile = file // Apply a system-level flock to the file - this is the key to robust locking if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { log.Printf("Could not acquire system lock: %v", err) lockFile.Close() os.Remove(lockFilePath) return false } // Successfully created and locked the file // Write PID, timestamp, and version to lock file pid := os.Getpid() timestamp := time.Now().Format(time.RFC3339) lockContent := fmt.Sprintf("%d\n%s\nmail2news v%s", pid, timestamp, VERSION) if _, err := lockFile.WriteString(lockContent); err != nil { log.Printf("Failed to write to lock file: %v", err) releaseLock() // Will release flock and remove file return false } log.Printf("Lock acquired with flock by PID %d", pid) return true } // releaseLock releases the process lock func releaseLock() { if lockFile != nil { // Release the system-level lock syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) lockFile.Close() lockFile = nil // Remove the lock file log.Printf("Releasing lock (PID %d)", os.Getpid()) err := os.Remove(lockFilePath) if err != nil && !os.IsNotExist(err) { log.Printf("Warning: Failed to remove lock file: %v", err) } } } // Initialize logging func initLogging() { // Make sure path is set if config.Paths.Log == "" { config.Paths.Log = "/var/log/mail2news/mail2news.log" } // Create directory if it doesn't exist logDir := filepath.Dir(config.Paths.Log) err := os.MkdirAll(logDir, 0755) if err != nil { log.Printf("Error creating log directory %s: %v", logDir, err) log.SetOutput(os.Stdout) log.SetFlags(log.Ldate | log.Ltime) log.Println("Logging initialized to stdout due to directory error") return } // Open or create log file logFile, err := os.OpenFile(config.Paths.Log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { log.Printf("Error opening log file %s: %v", config.Paths.Log, err) log.SetOutput(os.Stdout) log.SetFlags(log.Ldate | log.Ltime) log.Println("Logging initialized to stdout due to file error") return } // Set output to file log.SetOutput(logFile) log.SetFlags(log.Ldate | log.Ltime) log.Printf("Logging initialized to %s", config.Paths.Log) } // logMessage - wrapper for logging with levels func logMessage(message string, level string) { // Default to INFO if level not specified if level == "" { level = "INFO" } // Only log if level is sufficient based on config configLevel := strings.ToUpper(config.Logging.Level) if configLevel == "" { configLevel = "INFO" // Default to INFO } messageLevel := strings.ToUpper(level) // Determine if message should be logged based on level shouldLog := false switch configLevel { case "DEBUG": shouldLog = true // Log everything case "INFO": shouldLog = messageLevel != "DEBUG" // Log INFO, WARNING, ERROR case "WARNING": shouldLog = messageLevel == "WARNING" || messageLevel == "ERROR" // Log WARNING, ERROR case "ERROR": shouldLog = messageLevel == "ERROR" // Log only ERROR } if shouldLog { log.Printf("[%s] %s", messageLevel, message) } } // Function to convert string encoding with enhanced charset support func convertEncoding(input string, sourceEnc string, targetEnc string) (string, error) { if strings.EqualFold(sourceEnc, targetEnc) { return input, nil } var enc encoding.Encoding var err error // Enhanced encoding handling with more European charsets switch strings.ToLower(sourceEnc) { case "utf-8", "us-ascii": return input, nil case "iso-8859-1", "latin1": enc = charmap.ISO8859_1 case "iso-8859-2", "latin2": enc = charmap.ISO8859_2 case "iso-8859-3", "latin3": enc = charmap.ISO8859_3 case "iso-8859-4", "latin4": enc = charmap.ISO8859_4 case "iso-8859-5": enc = charmap.ISO8859_5 case "iso-8859-9", "latin5": enc = charmap.ISO8859_9 case "iso-8859-10", "latin6": enc = charmap.ISO8859_10 case "iso-8859-13", "latin7": enc = charmap.ISO8859_13 case "iso-8859-14", "latin8": enc = charmap.ISO8859_14 case "iso-8859-15", "latin9": enc = charmap.ISO8859_15 case "iso-8859-16": enc = charmap.ISO8859_16 case "windows-1250": enc = charmap.Windows1250 case "windows-1251": enc = charmap.Windows1251 case "windows-1252": enc = charmap.Windows1252 case "windows-1253": enc = charmap.Windows1253 case "windows-1254": enc = charmap.Windows1254 case "windows-1255": enc = charmap.Windows1255 case "windows-1256": enc = charmap.Windows1256 case "windows-1257": enc = charmap.Windows1257 case "windows-1258": enc = charmap.Windows1258 case "koi8-r": enc = charmap.KOI8R case "koi8-u": enc = charmap.KOI8U default: // Try to get from htmlindex enc, err = htmlindex.Get(sourceEnc) if err != nil { fallbackCharset := "iso-8859-1" // Default fallback if config.Encoding.FallbackCharset != "" { fallbackCharset = config.Encoding.FallbackCharset } logMessage(fmt.Sprintf("Encoding %s not recognized, using fallback %s", sourceEnc, fallbackCharset), "WARNING") // Use fallback charset enc, _ = htmlindex.Get(fallbackCharset) } } // Convert to UTF-8 reader := transform.NewReader(strings.NewReader(input), enc.NewDecoder()) result, err := ioutil.ReadAll(reader) if err != nil { return input, err } return string(result), nil } // Function to decode content based on Content-Transfer-Encoding func decodeTransferEncoding(content string, encoding string) (string, error) { encoding = strings.ToLower(encoding) switch encoding { case "quoted-printable": reader := quotedprintable.NewReader(strings.NewReader(content)) decoded, err := ioutil.ReadAll(reader) if err != nil { return content, err } logMessage(fmt.Sprintf("Successfully decoded quoted-printable content"), "DEBUG") return string(decoded), nil case "base64": // Remove any whitespace from base64 content which can cause decoding errors content = regexp.MustCompile(`\s+`).ReplaceAllString(content, "") decoded, err := base64.StdEncoding.DecodeString(content) if err != nil { return content, err } logMessage(fmt.Sprintf("Successfully decoded base64 content"), "DEBUG") return string(decoded), nil case "7bit", "8bit", "binary", "": // No decoding needed return content, nil default: logMessage(fmt.Sprintf("Unknown Content-Transfer-Encoding: %s", encoding), "WARNING") return content, nil } } // Determine appropriate Content-Transfer-Encoding for a message func determineContentTransferEncoding(content string) string { // Check if content has any non-ASCII characters has8bitChars := false hasBinaryChars := false maxLineLength := 0 lines := strings.Split(content, "\r\n") for _, line := range lines { // Track maximum line length if len(line) > maxLineLength { maxLineLength = len(line) } for _, c := range line { // Check for 8-bit characters (non-ASCII) if c > 127 { has8bitChars = true } // Check for control characters except tab, CR, LF if c < 32 && c != 9 && c != 10 && c != 13 { hasBinaryChars = true } } } // Determine encoding based on content characteristics if hasBinaryChars || maxLineLength > 998 { return "base64" // Binary content or very long lines need base64 } else if has8bitChars { return "8bit" // Non-ASCII content uses 8bit } else { return "7bit" // Plain ASCII with reasonable line length } } // Normalize email format to ensure NNTP compatibility with enhanced UTF-8 support func normalizeEmailFormat(message string) string { // Detect original encoding contentTypeRegex := regexp.MustCompile(`(?i)Content-Type:[^\n]*charset=["']?([^"'\r\n;]+)`) matches := contentTypeRegex.FindStringSubmatch(message) sourceEncoding := "utf-8" // Default to UTF-8 if len(matches) > 1 { sourceEncoding = strings.ToLower(matches[1]) logMessage(fmt.Sprintf("Detected charset in Content-Type: %s", sourceEncoding), "DEBUG") } // Find Content-Transfer-Encoding transferEncodingRegex := regexp.MustCompile(`(?i)Content-Transfer-Encoding:\s*([^\r\n]+)`) transferEncoding := "7bit" // Default matches = transferEncodingRegex.FindStringSubmatch(message) if len(matches) > 1 { transferEncoding = strings.TrimSpace(matches[1]) logMessage(fmt.Sprintf("Found Content-Transfer-Encoding: %s", transferEncoding), "DEBUG") } // Ensure proper header-body separation (exactly one blank line) headerEnd := strings.Index(message, "\r\n\r\n") if headerEnd == -1 { headerEnd = strings.Index(message, "\n\n") } if headerEnd != -1 { headers := message[:headerEnd] body := message[headerEnd:] // Normalize header-body separation to exactly one blank line body = regexp.MustCompile(`^\r?\n\r?\n+`).ReplaceAllString(body, "\r\n\r\n") // Decode body according to transfer encoding if len(body) > 4 { // Ensure there's content after \r\n\r\n decodedBody, err := decodeTransferEncoding(body[4:], transferEncoding) // Skip \r\n\r\n if err != nil { logMessage(fmt.Sprintf("Error decoding body with %s encoding: %v", transferEncoding, err), "WARNING") } else { // Ensure no leading blank lines in body content decodedBody = strings.TrimLeft(decodedBody, "\r\n \t") body = "\r\n\r\n" + decodedBody logMessage(fmt.Sprintf("Successfully decoded message body using %s encoding", transferEncoding), "DEBUG") } } message = headers + body } // Convert to UTF-8 if needed or force_utf8 is true forceUtf8 := config.Encoding.ForceUtf8 if sourceEncoding != "utf-8" && (forceUtf8 || sourceEncoding != "us-ascii") { converted, err := convertEncoding(message, sourceEncoding, "utf-8") if err != nil { logMessage(fmt.Sprintf("Error during encoding conversion: %v", err), "WARNING") } else { message = converted logMessage(fmt.Sprintf("Message converted from %s to UTF-8", sourceEncoding), "DEBUG") } } // Replace problematic characters message = strings.ReplaceAll(message, "\u2018", "'") // Left single quote message = strings.ReplaceAll(message, "\u2019", "'") // Right single quote message = strings.ReplaceAll(message, "\u201C", "\"") // Left double quote message = strings.ReplaceAll(message, "\u201D", "\"") // Right double quote message = strings.ReplaceAll(message, "\u2026", "...") // Ellipsis message = strings.ReplaceAll(message, "\u2013", "-") // En dash message = strings.ReplaceAll(message, "\u2014", "-") // Em dash // Process HTML content if present if strings.Contains(message, "]*>", "", "]*>", "", "]*>", "", "]*>", "", "]*>", "

", "]*>", "]*>", ""} for _, tag := range htmlTags { re := regexp.MustCompile(tag) message = re.ReplaceAllString(message, "") } // Convert common HTML entities message = strings.ReplaceAll(message, """, "\"") message = strings.ReplaceAll(message, "'", "'") message = strings.ReplaceAll(message, "<", "<") message = strings.ReplaceAll(message, ">", ">") message = strings.ReplaceAll(message, "&", "&") message = strings.ReplaceAll(message, " ", " ") } // Handle MIME-Version header // Remove existing MIME-Version headers to prevent malformed ones mimeVersionRegex := regexp.MustCompile(`(?i)MIME-Version:.*\r?\n`) message = mimeVersionRegex.ReplaceAllString(message, "") // Add our own correctly formatted MIME-Version header headerEnd = strings.Index(message, "\r\n\r\n") if headerEnd == -1 { headerEnd = strings.Index(message, "\n\n") } if headerEnd != -1 { message = message[:headerEnd] + "\r\nMIME-Version: 1.0\r\n" + message[headerEnd:] logMessage("Added standardized MIME-Version header", "DEBUG") } // Ensure Content-Type exists with UTF-8 charset if !regexp.MustCompile(`(?i)Content-Type:`).MatchString(message) { // Find the end of headers headerEnd := strings.Index(message, "\r\n\r\n") if headerEnd == -1 { headerEnd = strings.Index(message, "\n\n") if headerEnd != -1 { // Insert before empty line message = message[:headerEnd] + "\r\nContent-Type: text/plain; charset=utf-8\r\n" + message[headerEnd:] } } else { // Insert before empty line message = message[:headerEnd] + "\r\nContent-Type: text/plain; charset=utf-8\r\n" + message[headerEnd:] } } else { // Replace existing encoding with UTF-8 message = regexp.MustCompile(`(?i)(Content-Type:[^\n]*charset=)["']?[^"'\r\n;]+`). ReplaceAllString(message, "${1}utf-8") } // Ensure there's a blank line between headers and body (critical for NNTP) headerBodySeparator := "\r\n\r\n" if !strings.Contains(message, headerBodySeparator) { // Find where headers end (first blank line) parts := strings.SplitN(message, "\n\n", 2) if len(parts) == 2 { // Rebuild with correct \r\n message = strings.ReplaceAll(parts[0], "\n", "\r\n") + "\r\n\r\n" + strings.ReplaceAll(parts[1], "\n", "\r\n") logMessage("Normalized email format with correct header-body separator", "DEBUG") } } // Ensure all lines end with \r\n (standard NNTP format) message = strings.ReplaceAll(message, "\r\n", "\n") // First normalize to \n message = strings.ReplaceAll(message, "\n", "\r\n") // Then convert all \n to \r\n // Check for lines starting with "." (in NNTP protocol, a single "." indicates end of message) lines := strings.Split(message, "\r\n") for i, line := range lines { if line == "." { lines[i] = ".." logMessage("Found single dot line, replaced with '..'", "DEBUG") } else if strings.HasPrefix(line, ".") && line != ".." { lines[i] = "." + line logMessage("Found line starting with '.', doubled it", "DEBUG") } } return strings.Join(lines, "\r\n") } // Parse recipient similar to the Python version func parseRecipient(user string) (string, string, bool) { // Extract domain part if present if idx := strings.Index(user, "@"); idx != -1 { user = user[:idx] } // Regular expression to match mail2news format re := regexp.MustCompile(`(mail2news|mail2news_nospam)-([0-9]{8})-(.*)`) matches := re.FindStringSubmatch(user) if matches == nil { logMessage("Badly formatted recipient. Rejecting message.", "ERROR") os.Exit(0) } recipient := matches[1] timestamp := matches[2] newsgroups := matches[3] // Replace = separator with commas newsgroups = strings.ReplaceAll(newsgroups, "=", ",") // Check for nospam directive nospam := false if recipient == "mail2news_nospam" { logMessage("Message includes a nospam directive. Will munge headers accordingly.", "INFO") nospam = true } return timestamp, newsgroups, nospam } // Validate timestamp func validateStamp(stamp string) bool { // Parse the stamp into a time.Time layout := "20060102" parsedTime, err := time.Parse(layout, stamp) if err != nil { logMessage(fmt.Sprintf("Malformed date element: %v. Rejecting message.", err), "ERROR") os.Exit(0) } // Get current time and calculate boundaries now := time.Now().UTC() beforeTime := now.Add(-time.Duration(config.Thresholds.HoursPast) * time.Hour) afterTime := now.Add(time.Duration(config.Thresholds.HoursFuture) * time.Hour) // Check if within bounds if parsedTime.After(beforeTime) && parsedTime.Before(afterTime) { logMessage(fmt.Sprintf("Timestamp (%s) is valid and within bounds.", stamp), "DEBUG") return true } logMessage(fmt.Sprintf("Timestamp (%s) is out of bounds. Rejecting message.", stamp), "ERROR") os.Exit(0) return false } // Validate newsgroups func ngvalidate(newsgroups string) string { newsgroups = strings.TrimRight(newsgroups, ",") groups := strings.Split(newsgroups, ",") var goodng []string modfile := filepath.Join(config.Paths.Lib, "moderated.db") // Check if moderation file exists if _, err := os.Stat(modfile); err == nil { logMessage(fmt.Sprintf("Moderated groups file found at %s", modfile), "DEBUG") } // Check each group format re := regexp.MustCompile(`[a-z][a-z0-9]+(\.[0-9a-z-+_]+)+$`) for _, ng := range groups { ng = strings.TrimSpace(ng) if re.MatchString(ng) { // Check for duplicates isDuplicate := false for _, existingNg := range goodng { if existingNg == ng { logMessage(fmt.Sprintf("%s is duplicated in Newsgroups header. Dropping one instance of it.", ng), "INFO") isDuplicate = true break } } if !isDuplicate { // Add to good newsgroups goodng = append(goodng, ng) } } else { logMessage(fmt.Sprintf("%s is not a validated newsgroup, ignoring.", ng), "WARNING") } } // No valid newsgroups if len(goodng) < 1 { logMessage("Message has no valid newsgroups. Rejecting it.", "ERROR") os.Exit(0) } // Check crosspost limit if len(goodng) > config.Thresholds.MaxCrossposts { logMessage(fmt.Sprintf("Message contains %d newsgroups, exceeding crosspost limit of %d. Rejecting.", len(goodng), config.Thresholds.MaxCrossposts), "ERROR") os.Exit(0) } header := strings.Join(goodng, ",") logMessage(fmt.Sprintf("Validated Newsgroups header is: %s", header), "INFO") return header } // Generate message ID with guaranteed uniqueness to prevent duplicates func messageID(rightPart string) string { // Override the domain with our fixed privacy-enhanced domain if needed if rightPart == "" { rightPart = "tcpreset-nospam" } // Create unique ID using multiple uniqueness factors: // 1. Current timestamp with microsecond precision // 2. Random string // 3. Hash of message content (first 8 chars) now := time.Now().UTC() // Format: YYYYMMDDHHMMSS.microseconds.randomstring.contentHash@domain randomStr := generateRandomString(8) uniqHash := fmt.Sprintf("%d", rand.Int63()) // Additional randomness leftPart := fmt.Sprintf("%s.%d.%s.%s", now.Format("20060102150405"), // Standard timestamp now.UnixNano() % 1000000, // Microseconds for uniqueness randomStr, // Random string uniqHash[:8]) // Random hash prefix return "<" + leftPart + "@" + rightPart + ">" } // Generate random string for message ID func generateRandomString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) for i := range b { b[i] = charset[rand.Intn(len(charset))] } return string(b) } // Generate content hash for message content func generateContentHash(content string) string { hash := sha256.Sum256([]byte(content)) return fmt.Sprintf("%x", hash) } // Check blacklists func blacklistCheck(badFile string, text string) string { filename := filepath.Join(config.Paths.Etc, badFile) badList := file2list(filename) if len(badList) > 0 { pattern := strings.Join(badList, "|") re, err := regexp.Compile(pattern) if err != nil { logMessage(fmt.Sprintf("Error compiling regex from %s: %v", badFile, err), "ERROR") return "" } if match := re.FindString(text); match != "" { return match } } return "" } // Convert file to list func file2list(filename string) []string { var items []string if _, err := os.Stat(filename); os.IsNotExist(err) { return items } file, err := os.Open(filename) if err != nil { return items } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if idx := strings.Index(line, "#"); idx != -1 { line = line[:idx] } line = strings.TrimSpace(line) if line != "" { items = append(items, line) } } return items } // min function for Go versions < 1.21 func min(a, b int) int { if a < b { return a } return b } // Parse From header func fromParse(fromHdr string) (string, string) { var name, addy string matched := false // Pattern 1: "Name " re1 := regexp.MustCompile(`([^<>]*)<([^<>\s]+@[^<>\s]+)>$`) if matches := re1.FindStringSubmatch(fromHdr); matches != nil { name = matches[1] addy = matches[2] matched = true } // Pattern 2: "user@example.com (Name)" re2 := regexp.MustCompile(`([^<>\s]+@[^<>\s]+)\s+\(([^\(\)]*)\)$`) if !matched { if matches := re2.FindStringSubmatch(fromHdr); matches != nil { name = matches[2] addy = matches[1] matched = true } } // Pattern 3: "user@example.com" re3 := regexp.MustCompile(`([^<>\s]+@[^<>\s]+)$`) if !matched { if matches := re3.FindStringSubmatch(fromHdr); matches != nil { name = "" addy = matches[1] } } if addy != "" { addy = strings.ReplaceAll(addy, ".", "") addy = strings.ReplaceAll(addy, "@", "") } return name, addy } // Parse message func msgParse(message string) (string, string) { // Display first characters of received message for debug (reduced logging) firstChars := min(200, len(message)) logMessage(fmt.Sprintf("First %d characters of received message: %s", firstChars, message[:firstChars]), "DEBUG") // Remove ALL Unix mailbox style "From " lines lines := strings.Split(message, "\n") var cleanedLines []string fromLinesRemoved := 0 for _, line := range lines { // Look for "From " lines that don't contain ":" (characteristic of mailbox lines) if strings.HasPrefix(line, "From ") && !strings.Contains(line, ":") { fromLinesRemoved++ continue // Skip this line } cleanedLines = append(cleanedLines, line) } if fromLinesRemoved > 0 { logMessage(fmt.Sprintf("Removed %d mailbox-style 'From ' lines", fromLinesRemoved), "DEBUG") message = strings.Join(cleanedLines, "\n") } else { logMessage("No 'From ' lines found to remove", "DEBUG") } logMessage("Skipping history file write due to permission issues", "DEBUG") // Before parsing the email, ensure it has no other format anomalies message = normalizeEmailFormat(message) // Parse the email msg, err := mail.ReadMessage(strings.NewReader(message)) if err != nil { logMessage(fmt.Sprintf("Error parsing message: %v", err), "ERROR") os.Exit(1) } // Get message ID first to check if we've already processed it messageId := msg.Header.Get("Message-ID") // If message ID is present, check cache to see if we've already processed it if messageId != "" && messageCache != nil { if messageCache.IsProcessed(messageId) { logMessage(fmt.Sprintf("Message %s has already been processed recently. Skipping.", messageId), "INFO") os.Exit(0) // Exit with success status } } // Only log headers at DEBUG level if config.Logging.Level == "DEBUG" { logMessage("=== Headers received in email ===", "DEBUG") for k, v := range msg.Header { logMessage(fmt.Sprintf("Header: %s = %v", k, v), "DEBUG") } } // Check for authentication headers if xHashcash := msg.Header.Get("X-Hashcash"); xHashcash == "" { logMessage("WARNING: X-Hashcash not found in email!", "WARNING") } if xEd25519Pub := msg.Header.Get("X-Ed25519-Pub"); xEd25519Pub == "" { logMessage("WARNING: X-Ed25519-Pub not found in email!", "WARNING") } if xEd25519Sig := msg.Header.Get("X-Ed25519-Sig"); xEd25519Sig == "" { logMessage("WARNING: X-Ed25519-Sig not found in email!", "WARNING") } // Get message body for Content-Transfer-Encoding analysis body, err := ioutil.ReadAll(msg.Body) if err != nil { logMessage(fmt.Sprintf("Error reading message body: %v", err), "ERROR") os.Exit(1) } // Ensure there's a body, even minimal bodyContent := string(body) if len(bodyContent) == 0 || len(strings.TrimSpace(bodyContent)) == 0 { logMessage("WARNING: Empty message body detected! Adding placeholder text.", "WARNING") bodyContent = "This message had no content." body = []byte(bodyContent) } // Analyze body for Content-Transfer-Encoding - but only if not already set if msg.Header.Get("Content-Transfer-Encoding") == "" { transferEncoding := determineContentTransferEncoding(bodyContent) msg.Header["Content-Transfer-Encoding"] = []string{transferEncoding} logMessage(fmt.Sprintf("Analyzed and set Content-Transfer-Encoding to %s", transferEncoding), "INFO") } // Process Message-ID if not already present if messageId == "" { // Create a content hash to help make a unique Message ID contentHash := generateContentHash(bodyContent) // Call messageID function passing configuration parameter and content hash messageId = messageID(config.NNTP.MessageID + "." + contentHash[:8]) msg.Header["Message-ID"] = []string{messageId} logMessage(fmt.Sprintf("Processing message with no Message-ID. Assigned: %s", messageId), "INFO") } else { logMessage(fmt.Sprintf("Processing message: %s", messageId), "INFO") } // Process Date header if msg.Header.Get("Date") == "" { logMessage("Message has no Date header. Inserting current timestamp.", "INFO") msg.Header["Date"] = []string{time.Now().Format(time.RFC1123Z)} } // Process From header fromHeader := msg.Header.Get("From") if fromHeader != "" { if match := blacklistCheck("bad_from", fromHeader); match != "" { logMessage(fmt.Sprintf("From header matches '%s'. Rejecting.", match), "ERROR") os.Exit(1) } } else { logMessage("Message has no From header. Inserting a null one.", "INFO") msg.Header["From"] = []string{config.NNTP.DefaultFrom} } // Minimal References handling if refs := msg.Header.Get("References"); refs != "" { logMessage(fmt.Sprintf("THREADING - Original References: %s", refs), "INFO") // Only add angle brackets if completely missing, otherwise keep as is if !strings.Contains(refs, "<") { refs = "<" + refs } if !strings.Contains(refs, ">") { refs = refs + ">" } // Keep References as is (no splitting/rejoining) msg.Header["References"] = []string{refs} logMessage(fmt.Sprintf("THREADING - Using References with minimal changes: %s", refs), "INFO") // Set In-Reply-To to match References exactly msg.Header["In-Reply-To"] = []string{refs} logMessage("THREADING - Set In-Reply-To to match References exactly", "INFO") } else if strings.HasPrefix(strings.ToLower(msg.Header.Get("Subject")), "re:") { logMessage("WARNING - Message has Re: in subject but no References header!", "WARNING") } // Check for poison headers poisonFile := filepath.Join(config.Paths.Etc, "headers_poison") poisonHeaders := file2list(poisonFile) for _, header := range poisonHeaders { if msg.Header.Get(header) != "" { logMessage(fmt.Sprintf("Message contains a blacklisted %s header. Rejecting it.", header), "ERROR") os.Exit(0) } } // Get recipient info var recipient string if to := msg.Header.Get("X-Original-To"); to != "" { recipient = to } else if to := msg.Header.Get("To"); to != "" { recipient = to } else { recipient = "mail2news@m2n.mixmin.net" logMessage(fmt.Sprintf("Could not find recipient info. Guessing %s.", recipient), "WARNING") } if !strings.HasPrefix(recipient, "mail2news") { logMessage(fmt.Sprintf("Recipient %s is not us.", recipient), "ERROR") os.Exit(2) } // Process newsgroups nospam := false var dest string if ng := msg.Header.Get("Newsgroups"); ng != "" { dest = ng delete(msg.Header, "Newsgroups") logMessage(fmt.Sprintf("Message has a Newsgroups header of %s", dest), "INFO") if strings.HasPrefix(recipient, "mail2news_nospam") { nospam = true logMessage("Message includes a nospam directive. Will munge From headers accordingly.", "INFO") } } else { logMessage("No Newsgroups header, trying to parse recipient information", "INFO") var stamp string stamp, dest, nospam = parseRecipient(recipient) if !validateStamp(stamp) { logMessage("No Newsgroups header or valid recipient. Rejecting message.", "ERROR") os.Exit(0) } } // Validate newsgroups validatedGroups := ngvalidate(dest) msg.Header["Newsgroups"] = []string{validatedGroups} // Check for blacklisted newsgroups if match := blacklistCheck("bad_groups", validatedGroups); match != "" { logMessage(fmt.Sprintf("Newsgroups header matches '%s'. Rejecting.", match), "ERROR") os.Exit(1) } // Handle nospam mode if nospam { name, addy := fromParse(msg.Header.Get("From")) if addy != "" { delete(msg.Header, "Author-Supplied-Address") delete(msg.Header, "From") msg.Header["Author-Supplied-Address"] = []string{addy} msg.Header["From"] = []string{name + ""} } } // Process Subject header if subj := msg.Header.Get("Subject"); subj != "" { logMessage(fmt.Sprintf("Subject: %s", subj), "INFO") } else { logMessage("Message has no Subject header. Inserting a null one.", "INFO") msg.Header["Subject"] = []string{"None"} } // Check for Path header if path := msg.Header.Get("Path"); path != "" { logMessage(fmt.Sprintf("Message has a preloaded path header of %s", path), "DEBUG") } // Strip headers stripFile := filepath.Join(config.Paths.Etc, "headers_strip") stripHeaders := file2list(stripFile) // Headers to always preserve preserveHeaders := map[string]bool{ "X-Hashcash": true, "X-Ed25519-Pub": true, "X-Ed25519-Sig": true, } for _, header := range stripHeaders { // Don't remove headers we want to preserve if _, shouldPreserve := preserveHeaders[header]; !shouldPreserve { if msg.Header.Get(header) != "" { logMessage(fmt.Sprintf("Stripping header: %s", header), "DEBUG") delete(msg.Header, header) } } else { logMessage(fmt.Sprintf("Preserving authentication header: %s", header), "DEBUG") } } // Add gateway headers msg.Header["Path"] = []string{config.NNTP.Path} msg.Header["Organization"] = []string{"Tcpreset M2N Gateway"} msg.Header["X-Gateway-Info"] = []string{ config.NNTP.InjectionHost + "; mail-complaints-to=" + config.NNTP.Contact, } // Update User-Agent delete(msg.Header, "User-Agent") msg.Header["User-Agent"] = []string{fmt.Sprintf("mail2news-go v%s", VERSION)} // Fix MIME Version - ensure correctly formatted delete(msg.Header, "MIME-Version") delete(msg.Header, "Mime-Version") msg.Header["MIME-Version"] = []string{"1.0"} // Log important threading headers only at INFO level logMessage(fmt.Sprintf("THREADING CHECK - Message-ID: %s", msg.Header.Get("Message-ID")), "INFO") logMessage(fmt.Sprintf("THREADING CHECK - References: %s", msg.Header.Get("References")), "INFO") logMessage(fmt.Sprintf("THREADING CHECK - In-Reply-To: %s", msg.Header.Get("In-Reply-To")), "INFO") // Build the complete message txtMsg := "" // Build headers - ensure all headers are correctly formatted for k, vv := range msg.Header { for _, v := range vv { // Ensure header value is properly trimmed v = strings.TrimSpace(v) txtMsg += k + ": " + v + "\r\n" } } // Blank line between headers and body - CRITICAL for NNTP format txtMsg += "\r\n" // Format the body with proper line endings and dot-stuffing bodyStr := string(body) // Make sure body doesn't start with extra blank lines bodyStr = strings.TrimLeft(bodyStr, "\r\n \t") // Normalize line endings in body bodyStr = strings.ReplaceAll(bodyStr, "\r\n", "\n") bodyStr = strings.ReplaceAll(bodyStr, "\n", "\r\n") // Handle lines starting with "." bodyLines := strings.Split(bodyStr, "\r\n") for i, line := range bodyLines { if line == "." { bodyLines[i] = ".." } else if strings.HasPrefix(line, ".") && line != ".." { bodyLines[i] = "." + line } } bodyStr = strings.Join(bodyLines, "\r\n") txtMsg += bodyStr // Ensure message ends with CRLF if !strings.HasSuffix(txtMsg, "\r\n") { txtMsg += "\r\n" logMessage("Added missing final CRLF to message", "DEBUG") } size := len(txtMsg) if size > config.Thresholds.MaxBytes { logMessage(fmt.Sprintf("Message exceeds %d size limit. Rejecting.", config.Thresholds.MaxBytes), "ERROR") os.Exit(1) } logMessage(fmt.Sprintf("Message is %d bytes", size), "INFO") // Only log first 100 chars of message at INFO level logPreview := min(100, len(txtMsg)) logMessage(fmt.Sprintf("Message prepared for delivery: %s...", txtMsg[:logPreview]), "INFO") // Mark this message as processed in our cache if messageCache != nil && messageId != "" { messageCache.MarkProcessed(messageId) } return messageId, txtMsg } // Send news - improved to avoid duplicates func newsSend(mid string, content string) { // Generate a content hash to help track server attempts contentHash := generateContentHash(content) logMessage(fmt.Sprintf("Generated content hash for message tracking: %s", contentHash[:16]), "DEBUG") // Implementation for NNTP sending // Using Tor for all connections hostFile := filepath.Join(config.Paths.Etc, "nntphosts") hosts := file2list(hostFile) // Default Tor proxy address if not specified in config torProxyAddr := "127.0.0.1:9050" if config.NNTP.TorProxy != "" { torProxyAddr = config.NNTP.TorProxy } // Create a SOCKS5 dialer for Tor torDialer, err := proxy.SOCKS5("tcp", torProxyAddr, nil, proxy.Direct) if err != nil { logMessage(fmt.Sprintf("Error creating Tor dialer: %v", err), "ERROR") return } // Track successful delivery and attempted hosts to prevent duplicates successfulDelivery := false attemptedHosts := make(map[string]bool) // Build the ordered server list var serverList []string // Try primary onion server first if configured if config.NNTP.PrimaryOnion != "" { primaryServer := config.NNTP.PrimaryOnion if !strings.Contains(primaryServer, ":") { primaryServer += ":119" // Default NNTP port } serverList = append(serverList, primaryServer) } // Add fallback server if configured and different from primary if config.NNTP.FallbackServer != "" && config.NNTP.FallbackServer != config.NNTP.PrimaryOnion { fallbackServer := config.NNTP.FallbackServer if !strings.Contains(fallbackServer, ":") { fallbackServer += ":119" // Default NNTP port } serverList = append(serverList, fallbackServer) } // Add remaining servers from nntphosts file, avoiding duplicates for _, host := range hosts { if !strings.Contains(host, ":") { host += ":119" // Default NNTP port } // Skip if already in our list alreadyAdded := false for _, existingServer := range serverList { if existingServer == host { alreadyAdded = true break } } if !alreadyAdded { serverList = append(serverList, host) } } // Try each server in order until successful or all failed for _, host := range serverList { // Skip if already attempted if attemptedHosts[host] { logMessage(fmt.Sprintf("Skipping already attempted server: %s", host), "DEBUG") continue } attemptedHosts[host] = true logMessage(fmt.Sprintf("Attempting delivery to %s", host), "INFO") // Attempt delivery with specific error handling for duplicates err, isDuplicate := deliverViaTor(torDialer, host, mid, content) if err == nil { logMessage(fmt.Sprintf("✓ %s successfully delivered to %s", mid, host), "INFO") successfulDelivery = true // Exit early if delivery succeeded break } else if isDuplicate { // If message is a duplicate, count it as successful logMessage(fmt.Sprintf("✓ %s already exists on %s (duplicate)", mid, host), "INFO") successfulDelivery = true // But continue to other servers to check storage status } else { logMessage(fmt.Sprintf("✗ Delivery to %s failed: %v", host, err), "WARNING") } } if successfulDelivery { logMessage("Mail2news message successfully delivered or already exists", "INFO") } else { logMessage("Mail2news message delivery FAILED to all available servers", "WARNING") } } // Deliver message via Tor to a specific NNTP server // Returns (error, isDuplicate) - where isDuplicate indicates if the message was rejected as a duplicate func deliverViaTor(torDialer proxy.Dialer, host string, messageID string, content string) (error, bool) { // Create a dialer with timeout timeoutDialer := &net.Dialer{ Timeout: time.Duration(config.Thresholds.SocketTimeout) * time.Second, } // Use Tor for .onion addresses or if always_use_tor is enabled var conn net.Conn var err error if strings.Contains(host, ".onion") || config.NNTP.AlwaysUseTor { // Connect through Tor logMessage(fmt.Sprintf("Connecting to %s via Tor proxy", host), "INFO") conn, err = torDialer.Dial("tcp", host) } else { // Direct connection logMessage(fmt.Sprintf("Connecting directly to %s", host), "INFO") conn, err = timeoutDialer.Dial("tcp", host) } if err != nil { return fmt.Errorf("connection error: %v", err), false } defer conn.Close() // Set deadline for the entire conversation deadline := time.Now().Add(time.Duration(config.Thresholds.SocketTimeout) * time.Second) conn.SetDeadline(deadline) // Set up buffered reader/writer reader := bufio.NewReader(conn) writer := bufio.NewWriter(conn) // Read initial greeting resp, err := reader.ReadString('\n') if err != nil { return fmt.Errorf("error reading greeting: %v", err), false } if !strings.HasPrefix(resp, "200 ") && !strings.HasPrefix(resp, "201 ") { return fmt.Errorf("unexpected greeting: %s", resp), false } respTrimmed := strings.TrimSpace(resp) logMessage(fmt.Sprintf("Connected to %s, greeting: %s", host, respTrimmed), "INFO") // Send MODE READER for onion servers or if greeting indicates transit mode if strings.Contains(host, ".onion") || strings.Contains(respTrimmed, "transit mode") { logMessage(fmt.Sprintf("Sending MODE READER to %s", host), "INFO") _, err = writer.WriteString("MODE READER\r\n") if err != nil { return fmt.Errorf("error sending MODE READER: %v", err), false } err = writer.Flush() if err != nil { return fmt.Errorf("error flushing MODE READER: %v", err), false } // Read response to MODE READER resp, err = reader.ReadString('\n') if err != nil { return fmt.Errorf("error reading MODE READER response: %v", err), false } respTrimmed = strings.TrimSpace(resp) logMessage(fmt.Sprintf("MODE READER response from %s: %s", host, respTrimmed), "INFO") // Check if the server supports READER mode if !strings.HasPrefix(resp, "200") && !strings.HasPrefix(resp, "201") { // If MODE READER failed, try to continue anyway logMessage(fmt.Sprintf("MODE READER not supported on %s, continuing anyway", host), "WARNING") } } // Use POST protocol (more widely supported) logMessage(fmt.Sprintf("Sending POST command to %s", host), "INFO") _, err = writer.WriteString("POST\r\n") if err != nil { return fmt.Errorf("error sending POST: %v", err), false } err = writer.Flush() if err != nil { return fmt.Errorf("error flushing POST: %v", err), false } // Read response to POST resp, err = reader.ReadString('\n') if err != nil { return fmt.Errorf("error reading POST response: %v", err), false } respTrimmed = strings.TrimSpace(resp) logMessage(fmt.Sprintf("POST response from %s: %s", host, respTrimmed), "INFO") // Check if server accepted POST (code 340) if strings.HasPrefix(resp, "340 ") { // Send article content logMessage(fmt.Sprintf("Server %s accepted POST, sending article", host), "INFO") logMessage(fmt.Sprintf("Article sent to %s via POST, waiting for response...", host), "INFO") _, err = writer.WriteString(content + "\r\n.\r\n") if err != nil { return fmt.Errorf("error sending article via POST: %v", err), false } err = writer.Flush() if err != nil { return fmt.Errorf("error flushing article via POST: %v", err), false } // Read final response resp, err = reader.ReadString('\n') if err != nil { return fmt.Errorf("error reading POST final response: %v", err), false } respTrimmed = strings.TrimSpace(resp) logMessage(fmt.Sprintf("POST final response from %s: %s", host, respTrimmed), "INFO") // Check if response indicates a duplicate (435 or 435 Duplicate or similar) isDuplicate := strings.Contains(respTrimmed, "435") || strings.Contains(strings.ToLower(respTrimmed), "duplicate") // Check if article was accepted (code 240) if strings.HasPrefix(resp, "240 ") { // Send QUIT writer.WriteString("QUIT\r\n") writer.Flush() return nil, false } // Send QUIT writer.WriteString("QUIT\r\n") writer.Flush() return fmt.Errorf("article rejected via POST: %s", respTrimmed), isDuplicate } else { return fmt.Errorf("POST not accepted: %s", respTrimmed), false } } func main() { // Initial log to signal startup fmt.Printf("CANARY: Mail2News version %s with enhanced UTF-8 support is running!\n", VERSION) // Acquire lock to prevent multiple instances if !acquireLock() { fmt.Println("Another instance of mail2news is already running. Exiting.") os.Exit(0) } defer releaseLock() // Release lock when program exits // Seed random number generator rand.Seed(time.Now().UnixNano()) // Initialize config viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath("/etc/mail2news") viper.AddConfigPath("$HOME/.mail2news") viper.AddConfigPath(".") if err := viper.ReadInConfig(); err != nil { log.Fatalf("Fatal error config file: %s", err) } if err := viper.Unmarshal(&config); err != nil { log.Fatalf("Unable to decode config: %s", err) } // Set defaults for config if not specified if config.Paths.Log == "" { config.Paths.Log = "/var/log/mail2news/mail2news.log" } if config.Logging.Level == "" { config.Logging.Level = "WARNING" // Default to WARNING level } if config.Encoding.FallbackCharset == "" { config.Encoding.FallbackCharset = "iso-8859-1" } // Initialize logging initLogging() // Create message ID cache messageCache = NewMessageIDCache(5*time.Minute, "/var/lib/mail2news/cache") logMessage(fmt.Sprintf("Mail2news version %s initialized with UTF-8 support", VERSION), "INFO") fmt.Println("Type message here. Finish with Ctrl-D.") // Read message from stdin message, err := ioutil.ReadAll(os.Stdin) if err != nil { logMessage(fmt.Sprintf("Error reading from stdin: %v", err), "ERROR") os.Exit(1) } // Parse and process message mid, payload := msgParse(string(message)) // Send to news servers newsSend(mid, payload) logMessage("Mail2news program completed successfully", "INFO") }