package main import ( "bufio" "context" "fmt" "io" "log" "net" "net/mail" "os" "path/filepath" "strings" "time" "github.com/mjl-/sconf" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/sasl" "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtpclient" ) var submitconf struct { LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."` Host string `sconf-doc:"Host to dial for delivery, e.g. mail.."` Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."` TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."` STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."` Username string `sconf-doc:"For SMTP authentication."` Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5, PLAIN."` AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256. If not set, any mutually supported algorithm can be used, in order of most to least secure."` From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."` DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."` } func cmdConfigDescribeSendmail(c *cmd) { c.params = ">/etc/moxsubmit.conf" c.help = `Describe configuration for mox when invoked as sendmail.` if len(c.Parse()) != 0 { c.Usage() } err := sconf.Describe(os.Stdout, submitconf) xcheckf(err, "describe config") } func cmdSendmail(c *cmd) { c.params = "[-Fname] [ignoredflags] [-t] [\r\n", submitconf.From) var haveTo bool for { line, err := r.ReadString('\n') if err != nil && err != io.EOF { xcheckf(err, "reading message") } if line != "" { if !strings.HasSuffix(line, "\n") { line += "\n" } if !strings.HasSuffix(line, "\r\n") { line = line[:len(line)-1] + "\r\n" } if header && line == "\r\n" { // Bare \r\n marks end of header. if !haveTo { line = fmt.Sprintf("To: <%s>\r\n", recipient) + line } header = false } else if header { t := strings.SplitN(line, ":", 2) if len(t) != 2 { log.Fatalf("invalid message, missing colon in header") } k := strings.ToLower(t[0]) if k == "from" { // We already added a From header. if err == io.EOF { break } continue } else if tflag && k == "to" { if recipient != "" { log.Fatalf("only single To header allowed") } s := strings.TrimSpace(t[1]) if !strings.Contains(s, "@") { if submitconf.DefaultDestination == "" { log.Fatalf("recipient %q has no @ and no default destination is configured", s) } recipient = submitconf.DefaultDestination } else { addrs, err := mail.ParseAddressList(s) xcheckf(err, "parsing To address list") if len(addrs) != 1 { log.Fatalf("only single address allowed in To header") } recipient = addrs[0].Address } } if k == "to" { haveTo = true } } sb.WriteString(line) } if err == io.EOF { break } } msg := sb.String() if recipient == "" { log.Fatalf("no recipient") } // Message seems acceptable. We'll try to deliver it from here. If that fails, we // store the message in the users home directory. xcheckf := func(err error, format string, args ...any) { if err == nil { return } log.Printf("submit failed: %s: %s", fmt.Sprintf(format, args...), err) homedir, err := os.UserHomeDir() xcheckf(err, "finding homedir for storing message after failed delivery") maildir := filepath.Join(homedir, "moxsubmit.failures") os.Mkdir(maildir, 0700) f, err := os.CreateTemp(maildir, "newmsg.") xcheckf(err, "creating temp file for storing message after failed delivery") defer func() { if f != nil { if err := os.Remove(f.Name()); err != nil { log.Printf("removing temp file after failure storing failed delivery: %v", err) } } }() _, err = f.Write([]byte(msg)) xcheckf(err, "writing message to temp file after failed delivery") name := f.Name() err = f.Close() xcheckf(err, "closing message in temp file after failed delivery") f = nil log.Printf("saved message in %s", name) os.Exit(1) } addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port)) d := net.Dialer{Timeout: 30 * time.Second} conn, err := d.Dial("tcp", addr) xcheckf(err, "dial submit server") var auth []sasl.Client switch submitconf.AuthMethod { case "SCRAM-SHA-256": auth = []sasl.Client{sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password)} case "SCRAM-SHA-1": auth = []sasl.Client{sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password)} case "CRAM-MD5": auth = []sasl.Client{sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password)} case "PLAIN": auth = []sasl.Client{sasl.NewClientPlain(submitconf.Username, submitconf.Password)} default: auth = []sasl.Client{ sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password), sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password), sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), sasl.NewClientPlain(submitconf.Username, submitconf.Password), } } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() tlsMode := smtpclient.TLSSkip if submitconf.TLS { tlsMode = smtpclient.TLSStrictImmediate } else if submitconf.STARTTLS { tlsMode = smtpclient.TLSStrictStartTLS } ourHostname, err := dns.ParseDomain(submitconf.LocalHostname) xcheckf(err, "parsing our local hostname") var remoteHostname dns.Domain if net.ParseIP(submitconf.Host) != nil { remoteHostname, err = dns.ParseDomain(submitconf.Host) xcheckf(err, "parsing remote hostname") } client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, ourHostname, remoteHostname, auth) xcheckf(err, "open smtp session") err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false) xcheckf(err, "submit message") if err := client.Close(); err != nil { log.Printf("closing smtp session after message was sent: %v", err) } }