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
}