package main import ( "bufio" "bytes" "errors" "flag" "fmt" "io" "io/ioutil" "net/mail" "os" "os/exec" "path/filepath" "strings" "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) { sender := f.returnPath() mail.Header["Precedence"] = []string{"list"} mail.Header["Sender"] = []string{"<" + sender + ">"} mail.Header["Errors-To"] = []string{sender} mail.Header["List-Id"] = []string{"<" + listname + ">"} if !f.newsletter { mail.Header["List-Post"] = []string{""} } } func recipients(members hashset) ([]bulkRecipients, error) { rcpts := members.elements() return splitRecipients(rcpts), nil } func sendmail(original *mail.Message, mail string, returnPath string, recps string) error { 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 { return err("error on call to sendmail", e) } return nil } 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 } for _, bulk := range rcpts { err = sendmail(mail, flatmail, returnPath, bulk.String()) 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 }