package main import ( "bufio" "context" "errors" "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/mox-" "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)."` RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."` } type RequireTLSOption string const ( RequireTLSDefault RequireTLSOption = "" RequireTLSYes RequireTLSOption = "yes" RequireTLSNo RequireTLSOption = "no" ) 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 } if submitconf.RequireTLS == RequireTLSNo { line = "TLS-Required: No\r\n" + 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 } } if header && submitconf.RequireTLS == RequireTLSNo { sb.WriteString("TLS-Required: No\r\n") } 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. // Must only use xsavecheckf for error checking in the code below. xsavecheckf := 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") // note: not removing the partial file if writing/closing below fails. _, 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) xsavecheckf(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 tlsPKIX := false if submitconf.TLS { tlsMode = smtpclient.TLSImmediate tlsPKIX = true } else if submitconf.STARTTLS { tlsMode = smtpclient.TLSRequiredStartTLS tlsPKIX = true } else if submitconf.RequireTLS == RequireTLSYes { xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration") } ourHostname, err := dns.ParseDomain(submitconf.LocalHostname) xsavecheckf(err, "parsing our local hostname") var remoteHostname dns.Domain if net.ParseIP(submitconf.Host) == nil { remoteHostname, err = dns.ParseDomain(submitconf.Host) xsavecheckf(err, "parsing remote hostname") } // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password) opts := smtpclient.Opts{ Auth: auth, RootCAs: mox.Conf.Static.TLS.CertPool, } client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts) xsavecheckf(err, "open smtp session") err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes) xsavecheckf(err, "submit message") if err := client.Close(); err != nil { log.Printf("closing smtp session after message was sent: %v", err) } }