UFck

package main

import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net"
"net/http"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
)

type Config struct {
ListenAddr string

// Ghost uses Mailgun-style HTTP Basic Auth: user "api", pass "<key>"
APIKey        string
AllowedDomain string // optional allowlist for /v3/<domain>/messages

// Your SMTP relay
SMTPHost string
SMTPPort string
SMTPUser string // optional
SMTPPass string // optional
SMTPMode string // "plain" | "starttls" | "smtps"

DefaultFrom string

}

func mustEnv(k string) string {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
log.Fatalf("missing env var: %s", k)
}
return v
}
func env(k, def string) string {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return def
}
return v
}

func loadConfig() Config {
return Config{
ListenAddr: env("LISTEN_ADDR", "0.0.0.0:8080"),

	APIKey:        mustEnv("MG_API_KEY"),
	AllowedDomain: env("MG_ALLOWED_DOMAIN", ""),

	SMTPHost: mustEnv("SMTP_HOST"),
	SMTPPort: env("SMTP_PORT", "25"),
	SMTPUser: env("SMTP_USER", ""),
	SMTPPass: env("SMTP_PASS", ""),
	SMTPMode: strings.ToLower(env("SMTP_MODE", "plain")), // plain | starttls | smtps

	DefaultFrom: env("DEFAULT_FROM", ""),
}

}

type mgRequest struct {
From string
To []string
Cc []string
Bcc []string
Subject string
Text string
HTML string

Attachments []filePart
Inline      []filePart

}

type filePart struct {
Filename string
ContentType string
Data []byte
}

type statusWriter struct {
http.ResponseWriter
status int
}

func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}

func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(sw, r)
log.Printf("%s %s -> %d (%s) from=%s", r.Method, r.URL.Path, sw.status, time.Since(start), r.RemoteAddr)
})
}

func main() {
cfg := loadConfig()

mux := http.NewServeMux()
mux.HandleFunc("/v3/", func(w http.ResponseWriter, r *http.Request) {
	// Expect: POST /v3/<domain>/messages
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if !checkBasicAuth(r, cfg.APIKey) {
		w.Header().Set("WWW-Authenticate", `Basic realm="mailgun"`)
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	domain, ok := parseDomainFromPath(r.URL.Path)
	if !ok {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	if cfg.AllowedDomain != "" && domain != cfg.AllowedDomain {
		http.Error(w, "forbidden domain", http.StatusForbidden)
		return
	}

	req, err := parseMailgunLikeRequest(r)
	if err != nil {
		http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
		return
	}

	log.Printf("mail: from=%q to=%d cc=%d bcc=%d subject=%q text=%d html=%d att=%d inline=%d",
		req.From, len(req.To), len(req.Cc), len(req.Bcc), req.Subject, len(req.Text), len(req.HTML), len(req.Attachments), len(req.Inline))

	ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
	defer cancel()

	if err := sendSMTP(ctx, cfg, req); err != nil {
		log.Printf("send failed domain=%s err=%v", domain, err)
		http.Error(w, "send failed: "+err.Error(), http.StatusBadGateway)
		return
	}

	// Mailgun-ish response Ghost is happy with
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"message":"Queued. Thank you.","id":"<proxy@local>"}`))
})

srv := &http.Server{
	Addr:              cfg.ListenAddr,
	Handler:           logRequests(mux),
	ReadHeaderTimeout: 10 * time.Second,
}

log.Printf("mailgun2smtp listening on %s", cfg.ListenAddr)
log.Fatal(srv.ListenAndServe())

}

func parseDomainFromPath(p string) (string, bool) {
// /v3//messages
p = strings.TrimPrefix(p, "/")
parts := strings.Split(p, "/")
if len(parts) != 3 {
return "", false
}
if parts[0] != "v3" || parts[2] != "messages" {
return "", false
}
if parts[1] == "" {
return "", false
}
return parts[1], true
}

func checkBasicAuth(r *http.Request, apiKey string) bool {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Basic ") {
return false
}
dec, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic "))
if err != nil {
return false
}
s := string(dec)
i := strings.IndexByte(s, ':')
if i < 0 {
return false
}
user := s[:i]
pass := s[i+1:]

if subtle.ConstantTimeCompare([]byte(user), []byte("api")) != 1 {
	return false
}
if subtle.ConstantTimeCompare([]byte(pass), []byte(apiKey)) != 1 {
	return false
}
return true

}

func parseMailgunLikeRequest(r *http.Request) (*mgRequest, error) {
ct := r.Header.Get("Content-Type")

// Ghost typically sends multipart/form-data; support urlencoded too.
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
	if err := r.ParseForm(); err != nil {
		return nil, err
	}
	return normalizeReq(&mgRequest{
		From:    strings.TrimSpace(r.FormValue("from")),
		To:      splitAddrs(r.Form["to"], r.FormValue("to")),
		Cc:      splitAddrs(r.Form["cc"], r.FormValue("cc")),
		Bcc:     splitAddrs(r.Form["bcc"], r.FormValue("bcc")),
		Subject: strings.TrimSpace(r.FormValue("subject")),
		Text:    r.FormValue("text"),
		HTML:    r.FormValue("html"),
	})
}

if !strings.HasPrefix(ct, "multipart/form-data") {
	return nil, fmt.Errorf("unsupported content-type: %s", ct)
}

if err := r.ParseMultipartForm(32 << 20); err != nil {
	return nil, err
}

req := &mgRequest{
	From:    strings.TrimSpace(r.FormValue("from")),
	To:      splitAddrs(r.MultipartForm.Value["to"], r.FormValue("to")),
	Cc:      splitAddrs(r.MultipartForm.Value["cc"], r.FormValue("cc")),
	Bcc:     splitAddrs(r.MultipartForm.Value["bcc"], r.FormValue("bcc")),
	Subject: strings.TrimSpace(r.FormValue("subject")),
	Text:    r.FormValue("text"),
	HTML:    r.FormValue("html"),
}

readFiles := func(key string) ([]filePart, error) {
	fhs := r.MultipartForm.File[key]
	if len(fhs) == 0 {
		return nil, nil
	}
	out := make([]filePart, 0, len(fhs))
	for _, fh := range fhs {
		f, err := fh.Open()
		if err != nil {
			return nil, err
		}
		data, err := io.ReadAll(io.LimitReader(f, 50<<20))
		f.Close()
		if err != nil {
			return nil, err
		}
		out = append(out, filePart{
			Filename:    filepath.Base(fh.Filename),
			ContentType: fh.Header.Get("Content-Type"),
			Data:        data,
		})
	}
	return out, nil
}

var err error
req.Attachments, err = readFiles("attachment")
if err != nil {
	return nil, err
}
req.Inline, err = readFiles("inline")
if err != nil {
	return nil, err
}

return normalizeReq(req)

}

func normalizeReq(req *mgRequest) (*mgRequest, error) {
if len(req.To) == 0 {
return nil, errors.New("missing to")
}
if strings.TrimSpace(req.Subject) == "" {
req.Subject = "(no subject)"
}
return req, nil
}

func splitAddrs(list []string, single string) []string {
var raw []string
raw = append(raw, list...)
if strings.TrimSpace(single) != "" {
raw = append(raw, single)
}

var out []string
for _, s := range raw {
	for _, part := range strings.Split(s, ",") {
		p := strings.TrimSpace(part)
		if p != "" {
			out = append(out, p)
		}
	}
}
return out

}

// buildMessageForRecipient creates a per-recipient message so the To: header does NOT leak the full list.
func buildMessageForRecipient(req *mgRequest, rcpt string) ([]byte, error) {
tmp := *req
tmp.To = []string{rcpt}
tmp.Cc = nil
tmp.Bcc = nil
return buildMessage(&tmp)
}

// sendSMTP sends ONE message per recipient (privacy-safe), reusing a single SMTP connection.
func sendSMTP(ctx context.Context, cfg Config, req *mgRequest) error {
from := strings.TrimSpace(req.From)
if from == "" {
from = cfg.DefaultFrom
}
if from == "" {
return errors.New("missing from (set DEFAULT_FROM or send from=)")
}

envFrom := extractAddr(from)
if envFrom == "" {
	envFrom = from
}

// Collect ALL recipients (Mailgun API lets Ghost send many).
all := append([]string{}, req.To...)
all = append(all, req.Cc...)
all = append(all, req.Bcc...)
if len(all) == 0 {
	return errors.New("no recipients")
}

// Normalize to bare addresses for SMTP RCPT.
recips := make([]string, 0, len(all))
for _, r := range all {
	if a := extractAddr(r); a != "" {
		recips = append(recips, a)
	} else {
		recips = append(recips, strings.TrimSpace(r))
	}
}

addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort)

var (
	c    *smtp.Client
	conn net.Conn
	err  error
)

switch cfg.SMTPMode {
case "smtps":
	tconn, e := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, &tls.Config{
		ServerName: cfg.SMTPHost,
		MinVersion: tls.VersionTLS12,
	})
	if e != nil {
		return fmt.Errorf("smtps dial: %w", e)
	}
	conn = tconn
	_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
	c, err = smtp.NewClient(conn, cfg.SMTPHost)
	if err != nil {
		return fmt.Errorf("smtp client: %w", err)
	}

default:
	conn, err = (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, "tcp", addr)
	if err != nil {
		return fmt.Errorf("smtp dial: %w", err)
	}
	_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
	c, err = smtp.NewClient(conn, cfg.SMTPHost)
	if err != nil {
		return fmt.Errorf("smtp client: %w", err)
	}
}

defer func() { _ = c.Quit() }()

if cfg.SMTPMode == "starttls" {
	if ok, _ := c.Extension("STARTTLS"); !ok {
		return errors.New("smtp server does not support STARTTLS")
	}
	if err := c.StartTLS(&tls.Config{
		ServerName: cfg.SMTPHost,
		MinVersion: tls.VersionTLS12,
	}); err != nil {
		return fmt.Errorf("starttls: %w", err)
	}
}

if cfg.SMTPUser != "" {
	log.Printf("smtp auth as %q to %s:%s mode=%s", cfg.SMTPUser, cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPMode)
	auth := smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPHost)
	if err := c.Auth(auth); err != nil {
		return fmt.Errorf("smtp auth: %w", err)
	}
}

// IMPORTANT: One message per recipient so headers don't leak the list.
for _, rcpt := range recips {
	msgBytes, err := buildMessageForRecipient(req, rcpt)
	if err != nil {
		return err
	}

	if err := c.Reset(); err != nil {
		return fmt.Errorf("smtp reset: %w", err)
	}
	if err := c.Mail(envFrom); err != nil {
		return fmt.Errorf("smtp MAIL FROM: %w", err)
	}
	if err := c.Rcpt(rcpt); err != nil {
		return fmt.Errorf("smtp RCPT TO %s: %w", rcpt, err)
	}

	w, err := c.Data()
	if err != nil {
		return fmt.Errorf("smtp DATA: %w", err)
	}
	if _, err := w.Write(msgBytes); err != nil {
		_ = w.Close()
		return fmt.Errorf("smtp write: %w", err)
	}
	if err := w.Close(); err != nil {
		return fmt.Errorf("smtp close: %w", err)
	}
}

return nil

}

func buildMessage(req *mgRequest) ([]byte, error) {
var buf bytes.Buffer

// Headers
writeHeader(&buf, "From", req.From)
writeHeader(&buf, "To", strings.Join(req.To, ", "))
if len(req.Cc) > 0 {
	writeHeader(&buf, "Cc", strings.Join(req.Cc, ", "))
}
writeHeader(&buf, "Subject", encodeHeaderIfNeeded(req.Subject))
writeHeader(&buf, "Date", time.Now().Format(time.RFC1123Z))
writeHeader(&buf, "MIME-Version", "1.0")

hasAttach := len(req.Attachments) > 0 || len(req.Inline) > 0
hasText := strings.TrimSpace(req.Text) != ""
hasHTML := strings.TrimSpace(req.HTML) != ""

if !hasText && !hasHTML {
	req.Text = "(empty message)"
	hasText = true
}

if hasAttach {
	mixed := multipart.NewWriter(&buf)
	writeHeader(&buf, "Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mixed.Boundary()))
	buf.WriteString("\r\n") // end headers

	// Body part (may be alternative)
	bodyBytes, bodyCT, err := buildBodyPart(hasText, hasHTML, req.Text, req.HTML)
	if err != nil {
		return nil, err
	}
	p, err := mixed.CreatePart(textproto.MIMEHeader{
		"Content-Type": []string{bodyCT},
	})
	if err != nil {
		return nil, err
	}
	_, _ = p.Write(bodyBytes)

	// Attachments
	for _, a := range req.Attachments {
		ct := a.ContentType
		if ct == "" {
			ct = "application/octet-stream"
		}
		p, err := mixed.CreatePart(textproto.MIMEHeader{
			"Content-Type":              []string{ct},
			"Content-Disposition":       []string{fmt.Sprintf(`attachment; filename="%s"`, escapeQuotes(a.Filename))},
			"Content-Transfer-Encoding": []string{"base64"},
		})
		if err != nil {
			return nil, err
		}
		writeBase64Wrapped(p, a.Data)
	}

	// Inline
	for _, a := range req.Inline {
		ct := a.ContentType
		if ct == "" {
			ct = "application/octet-stream"
		}
		p, err := mixed.CreatePart(textproto.MIMEHeader{
			"Content-Type":              []string{ct},
			"Content-Disposition":       []string{fmt.Sprintf(`inline; filename="%s"`, escapeQuotes(a.Filename))},
			"Content-Transfer-Encoding": []string{"base64"},
		})
		if err != nil {
			return nil, err
		}
		writeBase64Wrapped(p, a.Data)
	}

	_ = mixed.Close()
	return buf.Bytes(), nil
}

// No attachments: single or alternative
bodyBytes, bodyCT, err := buildBodyPart(hasText, hasHTML, req.Text, req.HTML)
if err != nil {
	return nil, err
}
writeHeader(&buf, "Content-Type", bodyCT)
buf.WriteString("\r\n")
buf.Write(bodyBytes)
return buf.Bytes(), nil

}

func buildBodyPart(hasText, hasHTML bool, textBody, htmlBody string) ([]byte, string, error) {
if hasText && hasHTML {
var b bytes.Buffer
alt := multipart.NewWriter(&b)

	// text/plain
	p1, err := alt.CreatePart(textproto.MIMEHeader{
		"Content-Type": []string{`text/plain; charset="utf-8"`},
	})
	if err != nil {
		return nil, "", err
	}
	_, _ = p1.Write([]byte(textBody))

	// text/html
	p2, err := alt.CreatePart(textproto.MIMEHeader{
		"Content-Type": []string{`text/html; charset="utf-8"`},
	})
	if err != nil {
		return nil, "", err
	}
	_, _ = p2.Write([]byte(htmlBody))

	_ = alt.Close()
	return b.Bytes(), fmt.Sprintf(`multipart/alternative; boundary="%s"`, alt.Boundary()), nil
}

if hasHTML {
	return []byte(htmlBody), `text/html; charset="utf-8"`, nil
}

// default text/plain
return []byte(textBody), `text/plain; charset="utf-8"`, nil

}

func writeHeader(buf *bytes.Buffer, k, v string) {
// guard against bare newlines
v = strings.ReplaceAll(v, "\r", "")
v = strings.ReplaceAll(v, "\n", "")
fmt.Fprintf(buf, "%s: %s\r\n", k, v)
}

func escapeQuotes(s string) string {
return strings.ReplaceAll(s, ", \")
}

func writeBase64Wrapped(w io.Writer, data []byte) {
enc := base64.StdEncoding.EncodeToString(data)
const lineLen = 76
for len(enc) > 0 {
n := lineLen
if len(enc) < n {
n = len(enc)
}
_, _ = io.WriteString(w, enc[:n]+"\r\n")
enc = enc[n:]
}
}

func extractAddr(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if a, err := mail.ParseAddress(s); err == nil {
return a.Address
}
parts := strings.Split(s, ",")
if len(parts) > 1 {
if a, err := mail.ParseAddress(strings.TrimSpace(parts[0])); err == nil {
return a.Address
}
}
if strings.Contains(s, "@") && !strings.ContainsAny(s, " <>") {
return s
}
return ""
}

func encodeHeaderIfNeeded(s string) string {
// If non-ASCII, encode as RFC 2047 encoded-word
for _, r := range s {
if r > 127 {
return mime.QEncoding.Encode("utf-8", s)
}
}
return s
}