// 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<>]+@[^\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, "@", "<AT>")
	obfuscated = strings.ReplaceAll(obfuscated, ".", "<DOT>")

	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 <nobody@anonymous.invalid>"
	}

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