2019-01-01 19:26:06 +01:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"bytes"
|
|
|
|
"errors"
|
|
|
|
"flag"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"net/mail"
|
|
|
|
"os"
|
|
|
|
"os/exec"
|
|
|
|
"path/filepath"
|
|
|
|
"strings"
|
2019-07-04 18:42:21 +02:00
|
|
|
"sync"
|
2019-01-01 19:26:06 +01:00
|
|
|
"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) {
|
2019-05-26 19:37:33 +02:00
|
|
|
bounceAddress := []string{"<" + f.returnPath() + ">"}
|
|
|
|
listAddress := []string{"<" + listname + ">"}
|
2019-01-01 19:26:06 +01:00
|
|
|
mail.Header["Precedence"] = []string{"list"}
|
2019-05-26 19:37:33 +02:00
|
|
|
mail.Header["Sender"] = bounceAddress
|
|
|
|
mail.Header["Errors-To"] = bounceAddress
|
|
|
|
mail.Header["List-Id"] = listAddress
|
2019-01-01 19:26:06 +01:00
|
|
|
if !f.newsletter {
|
|
|
|
mail.Header["List-Post"] = []string{"<mailto:" + listname + ">"}
|
2019-05-26 19:37:33 +02:00
|
|
|
mail.Header["Reply-To"] = listAddress
|
2019-01-01 19:26:06 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func recipients(members hashset) ([]bulkRecipients, error) {
|
|
|
|
rcpts := members.elements()
|
|
|
|
return splitRecipients(rcpts), nil
|
|
|
|
}
|
|
|
|
|
2019-07-04 18:42:21 +02:00
|
|
|
func sendmail(original *mail.Message, mail string, returnPath string, recps string, errChan chan error, wg *sync.WaitGroup) {
|
2019-01-01 19:26:06 +01:00
|
|
|
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 {
|
2019-07-04 18:42:21 +02:00
|
|
|
errChan <- err("error on call to sendmail", e)
|
2019-01-01 19:26:06 +01:00
|
|
|
}
|
2019-07-04 18:42:21 +02:00
|
|
|
wg.Done()
|
2019-01-01 19:26:06 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
2019-07-04 18:42:21 +02:00
|
|
|
var wg sync.WaitGroup
|
|
|
|
wg.Add(len(rcpts))
|
|
|
|
errChan := make(chan error)
|
2019-01-01 19:26:06 +01:00
|
|
|
for _, bulk := range rcpts {
|
2019-07-04 18:42:21 +02:00
|
|
|
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 {
|
2019-01-01 19:26:06 +01:00
|
|
|
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
|
|
|
|
}
|