package main import ( "bufio" "context" "crypto/tls" "encoding/base64" "fmt" "io" "log" "net" "net/mail" "os" "path/filepath" "strings" "time" "github.com/mjl-/sconf" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtpclient" ) 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) } var conn net.Conn addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port)) d := net.Dialer{Timeout: 30 * time.Second} if submitconf.TLS { conn, err = tls.DialWithDialer(&d, "tcp", addr, nil) } else { conn, err = d.Dial("tcp", addr) } xcheckf(err, "dial submit server") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() tlsMode := smtpclient.TLSStrict if !submitconf.STARTTLS { tlsMode = smtpclient.TLSSkip } // todo: should have more auth options, scram-sha-256 at least, perhaps cram-md5 for compatibility as well. authLine := fmt.Sprintf("AUTH PLAIN %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\u0000%s\u0000%s", submitconf.Username, submitconf.Password)))) mox.Conf.Static.HostnameDomain.ASCII = submitconf.LocalHostname client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, submitconf.Host, authLine) 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) } }