picolist/picolist.go
2020-11-21 10:28:24 +01:00

400 lines
9.1 KiB
Go

package main
import (
"bufio"
"bytes"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"net/mail"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const (
sendmailPathDefault = "/usr/sbin/sendmail"
defaultMaxSize = 6 * 1024 * 1024 // Bytes
explDomain = "the domain of your mail server (used for List-Id)"
explName = "name of list (used for List-Id)"
explMaxsize = "max valid size of received mail (bytes)"
explOpen = "allow non-members to post to the list"
explNewsletter = "newsletter mode (few-to-many, implies \"not open\" and an owner file in 'list'@'domain'.pico.owners)"
explDebug = "log even successful execution"
explSendmail = "path to sendmail"
program = "picolist"
maxRcpts = 50
logFile = "picolist.log"
)
type flags struct {
domain string
name string
maxsize int64
open bool
newsletter bool
}
func (f flags) listname() string {
return f.name + "@" + f.domain
}
func (f flags) returnPath() string {
return f.name + "-bounces" + "@" + f.domain
}
func parseFlags() flags {
domain := flag.String("domain", "", explDomain)
name := flag.String("list", "", explName)
maxsize := flag.Int("maxsize", defaultMaxSize, explMaxsize)
open := flag.Bool("open", false, explOpen)
newsletter := flag.Bool("newsletter", false, explNewsletter)
tmpDebug := flag.Bool("debug", false, explDebug)
tmpSendmail := flag.String("sendmail", sendmailPathDefault, explSendmail)
flag.Parse()
if *domain == "" || *name == "" {
log(nil, "Wrong usage")
}
debug = *tmpDebug
sendmailPath = *tmpSendmail
return flags{domain: *domain, name: *name, maxsize: int64(*maxsize), open: *open, newsletter: *newsletter}
}
func loadMembers(listname string) (hashset, error) {
return readAddressFile(recipientFile(listname))
}
func loadOwners(listname string, f flags, members hashset) (hashset, error) {
if f.newsletter {
return readAddressFile(ownerFile(listname))
} else {
return members, nil
}
}
func originator(mail *mail.Message) (string, error) {
header := mail.Header
if sender, ok := header["Sender"]; ok && len(sender) == 1 {
return parseAddress(sender[0])
} else {
if from, ok := header["From"]; ok && len(from) == 1 {
return parseAddress(from[0])
} else {
return "", err("malformed message as per RFC 5322 section-3.6.2, originator unclear", nil)
}
}
}
func isAllowed(mail *mail.Message, f flags, owners hashset) (bool, error) {
if !f.newsletter && f.open {
return true, nil
}
orig, err := originator(mail)
if err != nil {
return false, err
}
return owners.contains(orig), nil
}
func setHeaderFields(mail *mail.Message, listname string, f flags) {
dkim := mail.Header.Get("DKIM-Signature")
canChange := func(headerName string) bool {
headerName = ":" + headerName + ":"
headerNameLower := strings.ToLower(headerName)
return !strings.Contains(dkim, headerName) && !strings.Contains(dkim, headerNameLower)
}
bounceAddress := []string{"<" + f.returnPath() + ">"}
listAddress := []string{"<" + listname + ">"}
mail.Header["Precedence"] = []string{"list"}
if canChange("Sender") {
mail.Header["Sender"] = bounceAddress
}
mail.Header["Errors-To"] = bounceAddress
mail.Header["List-Id"] = listAddress
if !f.newsletter {
mail.Header["List-Post"] = []string{"<mailto:" + listname + ">"}
if canChange("Reply-To") {
mail.Header["Reply-To"] = listAddress
}
}
}
func recipients(members hashset) ([]bulkRecipients, error) {
rcpts := members.elements()
return splitRecipients(rcpts), nil
}
func sendmail(original *mail.Message, mail string, returnPath string, recps string, errChan chan error, wg *sync.WaitGroup) {
if debug {
log(original, sendmailPath+" -f "+returnPath+" "+recps+"\n")
}
cmd := exec.Command(sendmailPath, "-f "+returnPath, recps)
cmd.Stdin = strings.NewReader(mail)
e := cmd.Run()
if e != nil {
errChan <- err("error on call to sendmail", e)
}
wg.Done()
}
func forward(mail *mail.Message, returnPath string, members hashset) []error {
errs := []error{}
var err error
flatmail := asText(mail)
rcpts, err := recipients(members)
errs = appendErr(errs, err)
if len(errs) > 0 {
return errs
}
var wg sync.WaitGroup
wg.Add(len(rcpts))
errChan := make(chan error)
for _, bulk := range rcpts {
go sendmail(mail, flatmail, returnPath, bulk.String(), errChan, &wg)
}
go func(errChan chan error, wg *sync.WaitGroup) {
wg.Wait()
close(errChan)
}(errChan, &wg)
for err := range errChan {
errs = appendErr(errs, err)
}
return errs
}
func readMail(maxsize int64) (*mail.Message, error) {
var m *mail.Message
var e error
reader := bufio.NewReader(io.LimitReader(os.Stdin, maxsize))
// discard first line (envelope header from MTA)
_, e = reader.ReadBytes('\n')
if e == nil {
m, e = mail.ReadMessage(reader)
}
if e != nil {
return nil, err("could not read mail from stdin", e)
}
return m, e
}
func spreadMessage(f flags) {
listname := f.listname()
mail, e := readMail(f.maxsize)
optLogFatal(mail, e)
members, e := loadMembers(listname)
optLogFatal(mail, e)
owners, e := loadOwners(listname, f, members)
optLogFatal(mail, e)
allowed, e := isAllowed(mail, f, owners)
optLogFatal(mail, e)
returnPath := f.returnPath()
if allowed {
setHeaderFields(mail, listname, f)
errs := forward(mail, returnPath, members)
optLogAll(mail, errs)
} else if !f.newsletter {
log(mail, "not allowed to post to "+listname)
}
}
func main() {
exePath = filepath.Dir(os.Args[0])
flags := parseFlags()
spreadMessage(flags)
}
//
/*--------- helpers ---------*/
//
var exePath string
var debug bool
var sendmailPath string
func err(msg string, e error) error {
if e != nil {
msg += " - " + e.Error()
}
return errors.New(msg)
}
func appendErr(errs []error, e error) []error {
if e != nil {
errs = append(errs, e)
}
return errs
}
type hashset map[string]interface{}
func makeHashset() hashset {
return make(map[string]interface{})
}
func (s hashset) add(member string) error {
if s == nil {
return err("uninitialized hashset", nil)
}
s[member] = nil
return nil
}
func (s hashset) contains(member string) bool {
if s == nil {
return false
}
if _, ok := s[member]; ok {
return true
} else {
return false
}
}
func (set hashset) elements() []string {
keys := []string{}
for k, _ := range set {
keys = append(keys, k)
}
return keys
}
var logfile *os.File
func fullPath(file string) string {
return filepath.Join(exePath, file)
}
func ensureLogfile() bool {
if logfile == nil {
flags := os.O_APPEND | os.O_CREATE | os.O_WRONLY
logfile, _ = os.OpenFile(fullPath(logFile), flags, 0644)
if logfile == nil {
return false
}
}
return true
}
func log(mail *mail.Message, s string) {
if !ensureLogfile() {
// be quietly sad
return
}
if mail != nil {
header := mail.Header
from := header.Get("From")
to := header.Get("To")
subject := header.Get("Subject")
fmt.Fprintf(logfile, "%v FROM=%s TO=%s SUBJECT=%s -> %s\n", time.Now(), from, to, subject, s)
} else {
fmt.Fprintf(logfile, "%s%s", s, "\n")
}
}
func optLogFatal(mail *mail.Message, err error) {
if err != nil {
log(mail, err.Error())
// don't complain to the caller
// so that we don't accidentally spill
// information
os.Exit(0)
}
}
func optLogAll(mail *mail.Message, errs []error) {
for _, err := range errs {
log(mail, err.Error())
}
}
func recipientFile(listname string) string {
return fullPath(listname + ".pico")
}
func ownerFile(listname string) string {
return recipientFile(listname) + ".owners"
}
func readAddressFile(filename string) (hashset, error) {
addrs := makeHashset()
addrFile, e := ioutil.ReadFile(filename)
if e != nil {
return addrs, err("could not read address file "+filename, e)
}
tmpAddrs := strings.Split(string(addrFile), "\n")
for _, addr := range tmpAddrs {
addr = strings.TrimSpace(addr)
if addr != "" && !strings.HasPrefix(addr, "#") {
addrs.add(addr)
}
}
return addrs, nil
}
func writeHeader(buf *bytes.Buffer, key string, value string) {
if buf == nil {
return
}
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteString("\r\n")
}
func asText(mail *mail.Message) string {
if mail == nil {
return ""
}
var buf bytes.Buffer
header := mail.Header
for key, values := range header {
if key == "Received" {
for _, rcv := range values {
writeHeader(&buf, key, rcv)
}
} else {
writeHeader(&buf, key, strings.Join(values, ","))
}
}
buf.WriteString("\r\n")
buf.ReadFrom(mail.Body)
s := buf.String()
if debug {
log(nil, s)
}
return s
}
func parseAddress(s string) (string, error) {
addr, e := mail.ParseAddress(s)
if e != nil {
return "", err("could not parse originator address"+s, e)
} else {
return addr.Address, nil
}
}
type bulkRecipients []string
func (rcpts bulkRecipients) String() string {
return strings.Join([]string(rcpts), " ")
}
func splitRecipients(rcpts []string) []bulkRecipients {
all := []bulkRecipients{}
bulkRcpts := bulkRecipients([]string{})
for i := range rcpts {
bulkRcpts = append(bulkRcpts, rcpts[i])
if len(bulkRcpts)%maxRcpts == 0 || i == len(rcpts)-1 {
all = append(all, bulkRcpts)
bulkRcpts = []string{}
}
}
return all
}