mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
228 lines
7.1 KiB
Go
228 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
"github.com/therootcompany/golib/net/formmailer"
|
|
)
|
|
|
|
const (
|
|
name = "form2email"
|
|
licenseYear = "2026"
|
|
licenseOwner = "AJ ONeal"
|
|
licenseType = "CC0-1.0"
|
|
|
|
defaultBlocklistRepo = "https://github.com/bitwire-it/ipblocklist.git"
|
|
|
|
requestsPerMinute = 5
|
|
burstSize = 3
|
|
)
|
|
|
|
var (
|
|
version = "0.0.0-dev"
|
|
commit = "0000000"
|
|
date = "0001-01-01T00:00:00Z"
|
|
)
|
|
|
|
func printVersion(out io.Writer) {
|
|
if len(commit) > 7 {
|
|
commit = commit[:7]
|
|
}
|
|
_, _ = fmt.Fprintf(out, "%s v%s %s (%s)\n", name, version, commit, date)
|
|
_, _ = fmt.Fprintf(out, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
|
|
_, _ = fmt.Fprintf(out, "Licensed under the %s license\n", licenseType)
|
|
}
|
|
|
|
type MainConfig struct {
|
|
showVersion bool
|
|
listenAddr string
|
|
smtpHost string
|
|
smtpFrom string
|
|
smtpToList string
|
|
smtpUser string
|
|
smtpPass string
|
|
smtpSubject string
|
|
successFile string
|
|
errorFile string
|
|
blocklistRepo string
|
|
cacheDir string
|
|
geoipDir string
|
|
geoipBaseURL string
|
|
geoipConfPath string
|
|
}
|
|
|
|
func main() {
|
|
home, _ := os.UserHomeDir()
|
|
_ = godotenv.Load()
|
|
_ = godotenv.Load(filepath.Join(home, ".config/form2mail/env"))
|
|
|
|
cfg := MainConfig{
|
|
listenAddr: "localhost:3081",
|
|
smtpHost: os.Getenv("SMTP_HOST"),
|
|
smtpFrom: os.Getenv("SMTP_FROM"),
|
|
smtpToList: os.Getenv("SMTP_TO"),
|
|
smtpUser: os.Getenv("SMTP_USER"),
|
|
smtpSubject: "Website contact request from {.Email}",
|
|
successFile: "success-file.html",
|
|
errorFile: "error-file.html",
|
|
blocklistRepo: defaultBlocklistRepo,
|
|
}
|
|
|
|
fs := flag.NewFlagSet("", flag.ContinueOnError)
|
|
fs.BoolVar(&cfg.showVersion, "version", false, "Print version and exit")
|
|
fs.StringVar(&cfg.listenAddr, "listen", cfg.listenAddr, "Address to listen on")
|
|
fs.StringVar(&cfg.smtpHost, "smtp-host", cfg.smtpHost, "SMTP server:port e.g. smtp.gmail.com:587 (required)")
|
|
fs.StringVar(&cfg.smtpFrom, "smtp-from", cfg.smtpFrom, "Sender email e.g. you@gmail.com (required)")
|
|
fs.StringVar(&cfg.smtpToList, "smtp-to", cfg.smtpToList, "Recipient email e.g. alerts@yourdomain.com (required)")
|
|
fs.StringVar(&cfg.smtpUser, "smtp-user", cfg.smtpUser, "SMTP username (defaults to smtp-from if not set)")
|
|
fs.StringVar(&cfg.successFile, "success-file", cfg.successFile, "HTML or JSON file to reply with on success.")
|
|
fs.StringVar(&cfg.errorFile, "error-file", cfg.errorFile, "HTML or JSON file to reply with on failure.")
|
|
fs.StringVar(&cfg.blocklistRepo, "blocklist-repo", cfg.blocklistRepo, "git URL of the bitwire-it-compatible blocklist repo")
|
|
fs.StringVar(&cfg.cacheDir, "cache-dir", "", "cache parent dir (default: ~/.cache)")
|
|
fs.StringVar(&cfg.geoipDir, "geoip-dir", "", "dir holding GeoLite2 tarballs (default: <$cache-dir>/maxmind)")
|
|
fs.StringVar(&cfg.geoipBaseURL, "geoip-base-url", "", "MaxMind download base URL (default: https://download.maxmind.com/geoip/databases)")
|
|
fs.StringVar(&cfg.geoipConfPath, "geoip-conf", "", "path to GeoIP.conf (default: ./GeoIP.conf or ~/.config/maxmind/GeoIP.conf)")
|
|
|
|
fs.Usage = func() {
|
|
printVersion(os.Stderr)
|
|
fmt.Fprintln(os.Stderr, "\nUSAGE")
|
|
fmt.Fprintln(os.Stderr, " form2email [options]")
|
|
fs.PrintDefaults()
|
|
fmt.Fprintln(os.Stderr, "\nEnv vars (overrides flags): SMTP_HOST, SMTP_FROM, SMTP_TO, SMTP_USER, SMTP_PASS")
|
|
}
|
|
|
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
os.Exit(0)
|
|
}
|
|
fmt.Fprintln(os.Stderr, err)
|
|
fs.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
if cfg.showVersion {
|
|
printVersion(os.Stdout)
|
|
return
|
|
}
|
|
|
|
if cfg.smtpHost == "" || cfg.smtpFrom == "" || cfg.smtpToList == "" {
|
|
fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n")
|
|
fs.Usage()
|
|
fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n")
|
|
os.Exit(1)
|
|
}
|
|
printVersion(os.Stderr)
|
|
|
|
// Verify templates are readable at startup; re-read on each request so
|
|
// operators can edit HTML without restarting (matches legacy behavior).
|
|
successFallback, err := os.ReadFile(cfg.successFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "\nError: couldn't read success response file %q: %v\n\n", cfg.successFile, err)
|
|
os.Exit(1)
|
|
}
|
|
errorFallback, err := os.ReadFile(cfg.errorFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "\nError: couldn't read error response file %q: %v\n\n", cfg.errorFile, err)
|
|
os.Exit(1)
|
|
}
|
|
successBody := hotReload(cfg.successFile, successFallback)
|
|
errorBody := hotReload(cfg.errorFile, errorFallback)
|
|
|
|
if cfg.smtpUser == "" {
|
|
cfg.smtpUser = cfg.smtpFrom
|
|
}
|
|
|
|
if pass, hasPass := os.LookupEnv("SMTP_PASS"); !hasPass {
|
|
fmt.Fprintf(os.Stderr, "SMTP_PASS not set → ")
|
|
pwBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
log.Fatalf("Failed to read password: %v", err)
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
cfg.smtpPass = strings.TrimSpace(string(pwBytes))
|
|
} else {
|
|
cfg.smtpPass = pass
|
|
}
|
|
|
|
// Build FormMailer — all blocklist/GeoIP setup is handled by Run().
|
|
fields := []formmailer.Field{
|
|
{Label: "Name", FormName: "input_1", Kind: formmailer.KindText},
|
|
{Label: "Email", FormName: "input_3", Kind: formmailer.KindEmail},
|
|
{Label: "Phone", FormName: "input_4", Kind: formmailer.KindPhone},
|
|
{Label: "Company", FormName: "input_5", Kind: formmailer.KindText},
|
|
{Label: "Message", FormName: "input_7", Kind: formmailer.KindMessage},
|
|
}
|
|
|
|
fm := &formmailer.FormMailer{
|
|
ListenAddr: cfg.listenAddr,
|
|
SMTPHost: cfg.smtpHost,
|
|
SMTPFrom: cfg.smtpFrom,
|
|
SMTPTo: strings.Split(cfg.smtpToList, ","),
|
|
SMTPUser: cfg.smtpUser,
|
|
SMTPPass: cfg.smtpPass,
|
|
Subject: cfg.smtpSubject,
|
|
SuccessBody: successBody,
|
|
ErrorBody: errorBody,
|
|
ContentType: inferContentType(cfg.successFile),
|
|
HiddenSupportValue: "[REDACTED]",
|
|
TrustedProxies: []netip.Prefix{
|
|
netip.MustParsePrefix("127.0.0.0/8"),
|
|
netip.MustParsePrefix("::1/128"),
|
|
netip.MustParsePrefix("10.0.0.0/8"),
|
|
netip.MustParsePrefix("172.16.0.0/12"),
|
|
netip.MustParsePrefix("192.168.0.0/16"),
|
|
},
|
|
BlocklistRepo: cfg.blocklistRepo,
|
|
CacheDir: cfg.cacheDir,
|
|
GeoIPDir: cfg.geoipDir,
|
|
GeoIPBaseURL: cfg.geoipBaseURL,
|
|
GeoIPConfPath: cfg.geoipConfPath,
|
|
AllowedCountries: []string{"US", "CA", "MX", "CR", "VI"},
|
|
Fields: fields,
|
|
RPM: requestsPerMinute,
|
|
Burst: burstSize,
|
|
}
|
|
|
|
if err := fm.Run(context.Background()); err != nil {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
}
|
|
|
|
// hotReload returns a function that re-reads path on each call, falling back
|
|
// to the provided bytes on read error (logged). Used for templates that
|
|
// operators may edit out-of-band.
|
|
func hotReload(path string, fallback []byte) func() []byte {
|
|
return func() []byte {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
log.Printf("%s read: %v", path, err)
|
|
return fallback
|
|
}
|
|
return b
|
|
}
|
|
}
|
|
|
|
func inferContentType(path string) string {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".html", ".htm":
|
|
return "text/html; charset=utf-8"
|
|
case ".json":
|
|
return "application/json"
|
|
default:
|
|
return "text/plain; charset=utf-8"
|
|
}
|
|
}
|