// mail2news v1.0.5 - Privacy-Enhanced Mail to Usenet Gateway // Accepts both: // - mail2news-YYYYMMDD-newsgroup@domain (classic format) // - mail2news@domain (reads Newsgroups: from header) // Copyright 2025 // // v1.0.5 changelog: // - Removed adaptive padding (pointless on cleartext NNTP traffic) // - Removed StripPadding (was dead code, never called) // - Cleaned padding-related constants and config fields package main import ( "bufio" "bytes" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" "io" "log" "math/big" "mime" "mime/quotedprintable" "net" "net/mail" "net/textproto" "os" "path/filepath" "regexp" "runtime" "sort" "strings" "sync" "time" "github.com/spf13/viper" "golang.org/x/net/proxy" "golang.org/x/text/encoding/charmap" ) const ( MaxMessageSize = 1024 * 1024 * 10 // 10MB CacheExpiration = 24 * time.Hour MinDelayMs = 50 MaxDelayMs = 500 JitterMs = 100 ExitOK = 0 ExitError = 1 ExitReject = 2 ExitDuplicate = 3 ) // Config structures type Config struct { Paths PathsConfig `mapstructure:"paths"` NNTP NNTPConfig `mapstructure:"nntp"` Thresholds ThresholdsConfig `mapstructure:"thresholds"` Logging LoggingConfig `mapstructure:"logging"` Encoding EncodingConfig `mapstructure:"encoding"` Privacy PrivacyConfig `mapstructure:"privacy"` Headers HeadersConfig `mapstructure:"headers"` } type PathsConfig struct { Log string `mapstructure:"log"` Etc string `mapstructure:"etc"` Lib string `mapstructure:"lib"` History string `mapstructure:"history"` } type NNTPConfig struct { PathHeader string `mapstructure:"path_header"` InjectionHost string `mapstructure:"injection_host"` Contact string `mapstructure:"contact"` MessageIDDomain string `mapstructure:"messageid_domain"` DefaultFrom string `mapstructure:"default_from"` OnionServers []string `mapstructure:"onion_servers"` ClearnetServers []string `mapstructure:"clearnet_servers"` AlwaysUseTor bool `mapstructure:"always_use_tor"` TorProxy string `mapstructure:"tor_proxy"` } 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 { FallbackCharset string `mapstructure:"fallback_charset"` } type PrivacyConfig struct { EnableDelays bool `mapstructure:"enable_delays"` EnableCoverTraffic bool `mapstructure:"enable_cover_traffic"` StripAllMetadata bool `mapstructure:"strip_all_metadata"` RandomizeOrder bool `mapstructure:"randomize_order"` MultiHopRouting bool `mapstructure:"multi_hop_routing"` } type HeadersConfig struct { StripFile string `mapstructure:"strip_file"` StripList []string `mapstructure:"strip_list"` AddNoArchive bool `mapstructure:"add_no_archive"` AddGateway bool `mapstructure:"add_gateway_info"` GatewayInfo string `mapstructure:"gateway_info"` } // Message ID Cache type MessageIDCache struct { cache map[string]time.Time mu sync.RWMutex maxAge time.Duration cacheDir string } var ( config Config messageCache *MessageIDCache logFile *os.File headersToStrip map[string]bool ) // ============================================================================ // SECURE RANDOM FUNCTIONS // ============================================================================ func SecureRandomInt(max int) int { if max <= 0 { return 0 } n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) if err != nil { return 0 } return int(n.Int64()) } func SecureRandom(size int) ([]byte, error) { b := make([]byte, size) _, err := rand.Read(b) return b, err } func SecureRandomDelay() { if !config.Privacy.EnableDelays { return } delay := SecureRandomInt(MaxDelayMs-MinDelayMs) + MinDelayMs jitter := SecureRandomInt(JitterMs*2) - JitterMs totalDelay := delay + jitter if totalDelay < 0 { totalDelay = MinDelayMs } time.Sleep(time.Duration(totalDelay) * time.Millisecond) } func SecureZeroMemory(data []byte) { for i := range data { data[i] = 0 } runtime.KeepAlive(data) } func SecureCompare(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } // ============================================================================ // MESSAGE ID CACHE // ============================================================================ func NewMessageIDCache(maxAge time.Duration, cacheDir string) *MessageIDCache { if cacheDir != "" { os.MkdirAll(cacheDir, 0700) } cache := &MessageIDCache{ cache: make(map[string]time.Time), maxAge: maxAge, cacheDir: cacheDir, } cache.loadCache() go cache.cleanupLoop() return cache } func hashMessageID(mid string) string { h := sha256.Sum256([]byte(mid)) return hex.EncodeToString(h[:]) } func (c *MessageIDCache) Check(mid string) bool { c.mu.RLock() hash := hashMessageID(mid) _, exists := c.cache[hash] c.mu.RUnlock() return exists } func (c *MessageIDCache) Add(mid string) { c.mu.Lock() hash := hashMessageID(mid) c.cache[hash] = time.Now() c.mu.Unlock() c.saveCache() } func (c *MessageIDCache) loadCache() { if c.cacheDir == "" { return } cacheFile := filepath.Join(c.cacheDir, "msgid.cache") data, err := os.ReadFile(cacheFile) if err != nil { return } c.mu.Lock() defer c.mu.Unlock() lines := strings.Split(string(data), "\n") for _, line := range lines { parts := strings.SplitN(line, "|", 2) if len(parts) == 2 { if t, err := time.Parse(time.RFC3339, parts[1]); err == nil { if time.Since(t) < c.maxAge { c.cache[parts[0]] = t } } } } } func (c *MessageIDCache) saveCache() { if c.cacheDir == "" { return } cacheFile := filepath.Join(c.cacheDir, "msgid.cache") c.mu.RLock() var lines []string for hash, t := range c.cache { lines = append(lines, fmt.Sprintf("%s|%s", hash, t.Format(time.RFC3339))) } c.mu.RUnlock() os.WriteFile(cacheFile, []byte(strings.Join(lines, "\n")), 0600) } func (c *MessageIDCache) cleanupLoop() { ticker := time.NewTicker(time.Hour) for range ticker.C { c.mu.Lock() now := time.Now() for hash, t := range c.cache { if now.Sub(t) > c.maxAge { delete(c.cache, hash) } } c.mu.Unlock() c.saveCache() } } // ============================================================================ // HEADERS STRIPPING // ============================================================================ func loadHeadersToStrip() { headersToStrip = make(map[string]bool) // Default headers to always strip for privacy defaultStrip := []string{ "received", "x-originating-ip", "x-mailer", "x-mimeole", "x-msmail-priority", "x-ms-has-attach", "x-ms-tnef-correlator", "x-auto-response-suppress", "return-path", "delivered-to", "dkim-signature", "domainkey-signature", "arc-seal", "arc-message-signature", "arc-authentication-results", "authentication-results", "x-google-dkim-signature", "x-gm-message-state", "x-received", "x-google-smtp-source", } for _, h := range defaultStrip { headersToStrip[strings.ToLower(h)] = true } // Load from config strip_list for _, h := range config.Headers.StripList { headersToStrip[strings.ToLower(strings.TrimSpace(h))] = true } // Load from file if specified stripFile := config.Headers.StripFile if stripFile == "" { stripFile = "/etc/mail2news/headers_strip" } if data, err := os.ReadFile(stripFile); err == nil { lines := strings.Split(string(data), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") { headersToStrip[strings.ToLower(line)] = true } } logMessage("Loaded additional headers to strip from file", "INFO") } logMessage("Headers strip list loaded", "INFO") } func shouldStripHeader(headerName string) bool { return headersToStrip[strings.ToLower(headerName)] } // ============================================================================ // LOGGING // ============================================================================ func initLogging() { logPath := config.Paths.Log if logPath == "" { logPath = "/var/log/mail2news/mail2news.log" } os.MkdirAll(filepath.Dir(logPath), 0700) var err error logFile, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { log.Printf("Cannot open log file: %v", err) return } log.SetOutput(io.MultiWriter(os.Stderr, logFile)) log.SetFlags(log.Ldate | log.Ltime) } func logMessage(msg, level string) { // Sanitize log message to prevent injection msg = strings.ReplaceAll(msg, "\n", " ") msg = strings.ReplaceAll(msg, "\r", " ") log.Printf("[%s] %s", level, msg) } // ============================================================================ // RECIPIENT PARSING // ============================================================================ func parseRecipient(user string) (stamp string, newsgroups string, nospam bool) { if idx := strings.Index(user, "@"); idx != -1 { user = user[:idx] } // Classic format: mail2news[_nospam]-YYYYMMDD-newsgroup1=newsgroup2 re := regexp.MustCompile(`^(mail2news|mail2news_nospam)-([0-9]{8})-(.+)$`) matches := re.FindStringSubmatch(user) if matches == nil { return "", "", false } nospam = (matches[1] == "mail2news_nospam") stamp = matches[2] newsgroups = strings.ReplaceAll(matches[3], "=", ",") return stamp, newsgroups, nospam } func isSimpleRecipient(recipient string) (valid bool, nospam bool) { local := recipient if idx := strings.Index(recipient, "@"); idx != -1 { local = recipient[:idx] } local = strings.ToLower(local) if local == "mail2news" { return true, false } if local == "mail2news_nospam" { return true, true } return false, false } func validateStamp(stamp string) bool { layout := "20060102" parsedTime, err := time.Parse(layout, stamp) if err != nil { logMessage("Malformed date in recipient", "ERROR") return false } now := time.Now().UTC() beforeTime := now.Add(-time.Duration(config.Thresholds.HoursPast) * time.Hour) afterTime := now.Add(time.Duration(config.Thresholds.HoursFuture) * time.Hour) if parsedTime.After(beforeTime) && parsedTime.Before(afterTime) { logMessage("Timestamp valid", "INFO") return true } logMessage("Timestamp out of bounds", "WARNING") return false } // ============================================================================ // NEWSGROUP VALIDATION // ============================================================================ func ngvalidate(newsgroups string) string { newsgroups = strings.TrimRight(newsgroups, ",") groups := strings.Split(newsgroups, ",") var goodng []string re := regexp.MustCompile(`^[a-z][a-z0-9]+(\.[0-9a-z\-+_]+)+$`) for _, ng := range groups { ng = strings.TrimSpace(strings.ToLower(ng)) if re.MatchString(ng) { isDuplicate := false for _, existing := range goodng { if existing == ng { isDuplicate = true break } } if !isDuplicate { goodng = append(goodng, ng) } } } if len(goodng) == 0 { logMessage("No valid newsgroups found", "ERROR") os.Exit(ExitReject) } if len(goodng) > config.Thresholds.MaxCrossposts { logMessage("Crosspost limit exceeded", "ERROR") os.Exit(ExitReject) } return strings.Join(goodng, ",") } // ============================================================================ // FROM PARSING // ============================================================================ func fromParse(from string) (name, addr string) { address, err := mail.ParseAddress(from) if err != nil { re := regexp.MustCompile(`]+@[^\s<>]+)>?`) if matches := re.FindStringSubmatch(from); matches != nil { return "", matches[1] } return from, "" } return address.Name, address.Address } func mungeFrom(from string) string { name, addr := fromParse(from) if addr == "" { return from } obfuscated := strings.ReplaceAll(addr, "@", "") obfuscated = strings.ReplaceAll(obfuscated, ".", "") if name != "" { return fmt.Sprintf("%s <%s>", name, obfuscated) } return fmt.Sprintf("<%s>", obfuscated) } // ============================================================================ // MESSAGE PARSING - PRESERVE HEADERS // ============================================================================ func msgParse(rawMessage string) (string, string) { SecureRandomDelay() msg, err := mail.ReadMessage(strings.NewReader(rawMessage)) if err != nil { logMessage(fmt.Sprintf("Parse error: %v", err), "ERROR") os.Exit(ExitError) } // Get recipient from available headers recipient := "" if to := msg.Header.Get("To"); to != "" { recipient = to } if delivered := msg.Header.Get("Delivered-To"); delivered != "" { if recipient == "" { recipient = delivered } } if xOrigTo := msg.Header.Get("X-Original-To"); xOrigTo != "" { if recipient == "" { recipient = xOrigTo } } if recipient == "" { logMessage("No recipient found", "ERROR") os.Exit(ExitReject) } logMessage("Recipient accepted", "INFO") // Check if recipient is for us recipientLower := strings.ToLower(recipient) if !strings.Contains(recipientLower, "mail2news") { logMessage("Recipient not matching gateway prefix", "ERROR") os.Exit(ExitReject) } // Determine format and get newsgroups var newsgroups string var nospam bool if isSimple, isNospam := isSimpleRecipient(recipient); isSimple { logMessage("Simple recipient format detected", "INFO") nospam = isNospam newsgroups = msg.Header.Get("Newsgroups") if newsgroups == "" { logMessage("Simple format requires Newsgroups: header", "ERROR") os.Exit(ExitReject) } logMessage("Newsgroups found in header", "INFO") } else { if ng := msg.Header.Get("Newsgroups"); ng != "" { newsgroups = ng logMessage("Newsgroups found in header", "INFO") nospam = strings.Contains(strings.ToLower(recipient), "nospam") } else { stamp, ng, ns := parseRecipient(recipient) if stamp == "" || ng == "" { logMessage("Invalid recipient format and no Newsgroups header", "ERROR") os.Exit(ExitReject) } if !validateStamp(stamp) { logMessage("Invalid timestamp", "ERROR") os.Exit(ExitReject) } newsgroups = ng nospam = ns } } newsgroups = ngvalidate(newsgroups) logMessage("Newsgroups validated", "INFO") // Get or generate Message-ID // SECURITY: Always regenerate Message-ID for anonymity (ignore client's) // Format: standard Postfix-like pattern (timestamp.random@domain.invalid) randomBytes, _ := SecureRandom(8) messageID := fmt.Sprintf("<%d.%s@%s>", time.Now().Unix(), strings.ToUpper(hex.EncodeToString(randomBytes)), config.NNTP.MessageIDDomain) logMessage("Regenerated Message-ID for anonymity", "INFO") // Check for duplicate if messageCache.Check(messageID) { logMessage("Duplicate message detected", "WARNING") os.Exit(ExitDuplicate) } messageCache.Add(messageID) // Read body body, err := io.ReadAll(msg.Body) if err != nil { logMessage(fmt.Sprintf("Body read error: %v", err), "ERROR") os.Exit(ExitError) } bodyStr := decodeBody(msg, body) // ======================================================================== // BUILD HEADERS - Preserve original, strip unwanted, add required // ======================================================================== var newHeaders strings.Builder processedHeaders := make(map[string]bool) // Process From header (with optional nospam munging) from := msg.Header.Get("From") if from == "" { from = config.NNTP.DefaultFrom } if nospam { from = mungeFrom(from) logMessage("From address processed", "INFO") } newHeaders.WriteString(fmt.Sprintf("From: %s\r\n", from)) processedHeaders["from"] = true // Newsgroups (validated) newHeaders.WriteString(fmt.Sprintf("Newsgroups: %s\r\n", newsgroups)) processedHeaders["newsgroups"] = true // Subject subject := msg.Header.Get("Subject") if subject == "" { subject = "(no subject)" } newHeaders.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) processedHeaders["subject"] = true // Date date := msg.Header.Get("Date") if date == "" { date = time.Now().UTC().Format(time.RFC1123Z) } newHeaders.WriteString(fmt.Sprintf("Date: %s\r\n", date)) processedHeaders["date"] = true // Message-ID newHeaders.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID)) processedHeaders["message-id"] = true // Path newHeaders.WriteString(fmt.Sprintf("Path: %s\r\n", config.NNTP.PathHeader)) processedHeaders["path"] = true // Strip all recipient-related headers - prevent routing metadata leak processedHeaders["x-original-to"] = true processedHeaders["to"] = true processedHeaders["delivered-to"] = true // MIME headers - must reflect decoded body state newHeaders.WriteString("MIME-Version: 1.0\r\n") processedHeaders["mime-version"] = true // Determine correct Content-Type after charset conversion originalCT := msg.Header.Get("Content-Type") if originalCT != "" { mediaType, params, err := mime.ParseMediaType(originalCT) if err == nil && strings.HasPrefix(mediaType, "text/") { charset := "" if c, ok := params["charset"]; ok { charset = strings.ToLower(c) } // If charset was converted to UTF-8, update it if charset == "iso-8859-1" || charset == "iso-8859-15" || charset == "windows-1252" || charset == "" { newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") } else if charset == "utf-8" || charset == "us-ascii" { newHeaders.WriteString(fmt.Sprintf("Content-Type: %s; charset=%s\r\n", mediaType, charset)) } else { newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") } } else { // Non-text content type, preserve as-is newHeaders.WriteString(fmt.Sprintf("Content-Type: %s\r\n", originalCT)) } } else { newHeaders.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") } processedHeaders["content-type"] = true // Body was decoded from base64/qp, so transfer encoding is now 8bit newHeaders.WriteString("Content-Transfer-Encoding: 8bit\r\n") processedHeaders["content-transfer-encoding"] = true // X-No-Archive if config.Headers.AddNoArchive { if existing := msg.Header.Get("X-No-Archive"); existing == "" { newHeaders.WriteString("X-No-Archive: Yes\r\n") } } processedHeaders["x-no-archive"] = true // X-Gateway-Info if config.Headers.AddGateway { gatewayInfo := config.Headers.GatewayInfo if gatewayInfo == "" { gatewayInfo = "mail2news" } newHeaders.WriteString(fmt.Sprintf("X-Gateway-Info: %s\r\n", gatewayInfo)) } // Now process ALL remaining original headers, preserving them unless stripped // Get all header keys and sort them for consistent output var headerKeys []string for key := range msg.Header { headerKeys = append(headerKeys, key) } sort.Strings(headerKeys) for _, key := range headerKeys { keyLower := strings.ToLower(key) // Skip if already processed if processedHeaders[keyLower] { continue } // Skip if in strip list if shouldStripHeader(key) { continue } // Preserve this header values := msg.Header[key] for _, value := range values { newHeaders.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) } processedHeaders[keyLower] = true } logMessage("Headers processed", "INFO") // End headers newHeaders.WriteString("\r\n") // Combine headers and body fullMessage := newHeaders.String() + bodyStr return messageID, fullMessage } func decodeBody(msg *mail.Message, body []byte) string { contentType := msg.Header.Get("Content-Type") encoding := msg.Header.Get("Content-Transfer-Encoding") var decoded []byte switch strings.ToLower(encoding) { case "quoted-printable": reader := quotedprintable.NewReader(bytes.NewReader(body)) decoded, _ = io.ReadAll(reader) case "base64": decoded, _ = base64.StdEncoding.DecodeString(string(body)) default: decoded = body } // Handle charset conversion if contentType != "" { mediaType, params, err := mime.ParseMediaType(contentType) if err == nil && strings.HasPrefix(mediaType, "text/") { if charset, ok := params["charset"]; ok { charset = strings.ToLower(charset) if charset == "iso-8859-1" || charset == "iso-8859-15" || charset == "windows-1252" { decoder := charmap.ISO8859_1.NewDecoder() result, err := decoder.Bytes(decoded) if err == nil { decoded = result } } } } } return string(decoded) } // ============================================================================ // NNTP SENDING // ============================================================================ func newsSend(messageID, payload string) { SecureRandomDelay() servers := make([]string, len(config.NNTP.OnionServers)) copy(servers, config.NNTP.OnionServers) if config.Privacy.RandomizeOrder && len(servers) > 1 { for i := len(servers) - 1; i > 0; i-- { j := SecureRandomInt(i + 1) servers[i], servers[j] = servers[j], servers[i] } } for i, server := range servers { if err := postToServer(server, payload, true); err == nil { logMessage(fmt.Sprintf("Posted successfully (server %d)", i+1), "INFO") return } else { logMessage(fmt.Sprintf("Server %d failed", i+1), "WARNING") } } // Try clearnet fallback if configured if len(config.NNTP.ClearnetServers) > 0 && !config.NNTP.AlwaysUseTor { for i, server := range config.NNTP.ClearnetServers { if err := postToServer(server, payload, false); err == nil { logMessage(fmt.Sprintf("Posted successfully (clearnet %d)", i+1), "INFO") return } else { logMessage(fmt.Sprintf("Clearnet server %d failed", i+1), "WARNING") } } } logMessage("All servers failed", "ERROR") os.Exit(ExitError) } func postToServer(server, payload string, useTor bool) error { var conn net.Conn var err error timeout := time.Duration(config.Thresholds.SocketTimeout) * time.Second if timeout == 0 { timeout = 60 * time.Second } if strings.Contains(server, ".onion") || config.NNTP.AlwaysUseTor || useTor { torProxy := config.NNTP.TorProxy if torProxy == "" { torProxy = "127.0.0.1:9050" } dialer, err := proxy.SOCKS5("tcp", torProxy, nil, proxy.Direct) if err != nil { return fmt.Errorf("tor proxy error: %w", err) } conn, err = dialer.Dial("tcp", server) if err != nil { return fmt.Errorf("tor connect error: %w", err) } } else { conn, err = net.DialTimeout("tcp", server, timeout) if err != nil { return fmt.Errorf("connect error: %w", err) } } defer conn.Close() conn.SetDeadline(time.Now().Add(timeout)) reader := bufio.NewReader(conn) tp := textproto.NewReader(reader) // Read greeting code, _, err := tp.ReadCodeLine(200) if err != nil || code != 200 { return fmt.Errorf("greeting error: %d %v", code, err) } // MODE READER fmt.Fprintf(conn, "MODE READER\r\n") tp.ReadCodeLine(200) // POST fmt.Fprintf(conn, "POST\r\n") code, _, err = tp.ReadCodeLine(340) if err != nil || code != 340 { return fmt.Errorf("POST rejected: %d %v", code, err) } // Send message lines := strings.Split(payload, "\n") for _, line := range lines { line = strings.TrimRight(line, "\r") if strings.HasPrefix(line, ".") { line = "." + line } fmt.Fprintf(conn, "%s\r\n", line) } fmt.Fprintf(conn, ".\r\n") // Read response code, nntpMsg, err := tp.ReadCodeLine(240) if err != nil || code != 240 { return fmt.Errorf("post failed: %d %s %v", code, nntpMsg, err) } // QUIT fmt.Fprintf(conn, "QUIT\r\n") return nil } // ============================================================================ // MAIN // ============================================================================ func main() { // Load config viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath("/etc/mail2news") viper.AddConfigPath(".") if err := viper.ReadInConfig(); err != nil { log.Fatalf("Config error: %s", err) } if err := viper.Unmarshal(&config); err != nil { log.Fatalf("Config parse error: %s", err) } // Set defaults if config.Paths.Log == "" { config.Paths.Log = "/var/log/mail2news/mail2news.log" } if config.Thresholds.MaxCrossposts == 0 { config.Thresholds.MaxCrossposts = 3 } if config.Thresholds.HoursPast == 0 { config.Thresholds.HoursPast = 48 } if config.Thresholds.HoursFuture == 0 { config.Thresholds.HoursFuture = 24 } if config.NNTP.PathHeader == "" { config.NNTP.PathHeader = "not-for-mail" } if config.NNTP.MessageIDDomain == "" { config.NNTP.MessageIDDomain = "mail2news.local" } if config.NNTP.DefaultFrom == "" { config.NNTP.DefaultFrom = "Anonymous " } // Enable privacy features by default if !config.Privacy.EnableDelays { config.Privacy.EnableDelays = true config.Privacy.RandomizeOrder = true } // Enable gateway headers by default if !config.Headers.AddNoArchive { config.Headers.AddNoArchive = true } if !config.Headers.AddGateway { config.Headers.AddGateway = true } initLogging() loadHeadersToStrip() messageCache = NewMessageIDCache(CacheExpiration, "/var/lib/mail2news/cache") logMessage("Gateway initialized", "INFO") // Read message from stdin message, err := io.ReadAll(os.Stdin) if err != nil { logMessage("Input error", "ERROR") os.Exit(ExitError) } if len(message) > config.Thresholds.MaxBytes && config.Thresholds.MaxBytes > 0 { logMessage("Message exceeds size limit", "ERROR") os.Exit(ExitReject) } mid, payload := msgParse(string(message)) newsSend(mid, payload) // Secure cleanup SecureZeroMemory(message) logMessage("Completed successfully", "INFO") }