19 "github.com/mjl-/sconf"
21 "github.com/mjl-/mox/dns"
22 "github.com/mjl-/mox/mox-"
23 "github.com/mjl-/mox/sasl"
24 "github.com/mjl-/mox/smtp"
25 "github.com/mjl-/mox/smtpclient"
28var submitconf struct {
29 LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
30 Host string `sconf-doc:"Host to dial for delivery, e.g. mail.<domain>."`
31 Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
32 TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
33 STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
34 Username string `sconf-doc:"For SMTP authentication."`
35 Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."`
36 AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256-PLUS, SCRAM-SHA-256, SCRAM-SHA-1-PLUS, SCRAM-SHA-1, CRAM-MD5, PLAIN. If not set, any mutually supported algorithm can be used, in order listed, from most to least secure. It is recommended to specify the strongest authentication mechanism known to be implemented by the server, to prevent mechanism downgrade attacks."`
37 From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
38 DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
39 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."`
42type RequireTLSOption string
45 RequireTLSDefault RequireTLSOption = ""
46 RequireTLSYes RequireTLSOption = "yes"
47 RequireTLSNo RequireTLSOption = "no"
50func cmdConfigDescribeSendmail(c *cmd) {
51 c.params = ">/etc/moxsubmit.conf"
52 c.help = `Describe configuration for mox when invoked as sendmail.`
53 if len(c.Parse()) != 0 {
57 err := sconf.Describe(os.Stdout, submitconf)
58 xcheckf(err, "describe config")
61func cmdSendmail(c *cmd) {
62 c.params = "[-Fname] [ignoredflags] [-t] [<message]"
63 c.help = `Sendmail is a drop-in replacement for /usr/sbin/sendmail to deliver emails sent by unix processes like cron.
65If invoked as "sendmail", it will act as sendmail for sending messages. Its
66intention is to let processes like cron send emails. Messages are submitted to
67an actual mail server over SMTP. The destination mail server and credentials are
68configured in /etc/moxsubmit.conf, see mox config describe-sendmail. The From
69message header is rewritten to the configured address. When the addressee
70appears to be a local user, because without @, the message is sent to the
71configured default address.
73If submitting an email fails, it is added to a directory moxsubmit.failures in
74the user's home directory.
76Most flags are ignored to fake compatibility with other sendmail
77implementations. A single recipient or the -t flag with a To-header is required.
78With the -t flag, Cc and Bcc headers are not handled specially, so Bcc is not
79removed and the addresses do not receive the email.
81/etc/moxsubmit.conf should be group-readable and not readable by others and this
82binary should be setgid that group:
85 install -m 2755 -o root -g moxsubmit mox /usr/sbin/sendmail
86 touch /etc/moxsubmit.conf
87 chown root:moxsubmit /etc/moxsubmit.conf
88 chmod 640 /etc/moxsubmit.conf
89 # edit /etc/moxsubmit.conf
92 // We are faking that we parse flags, this is non-standard, we want to be lax and ignore most flags.
94 c.flagArgs = []string{}
95 c.Parse() // We still have to call Parse for the usage gathering.
97 // Typical cron usage of sendmail:
98 // anacron: https://salsa.debian.org/debian/anacron/-/blob/c939c8c80fc9419c11a5e6be5cbe84f03ad332fd/runjob.c#L183
99 // cron: https://github.com/vixie/cron/blob/fea7a6c5421f88f034be8eef66a84d8b65b5fbe0/config.h#L41
102 var tflag bool // If set, we need to take the recipient(s) from the message headers. We only do one recipient, in To.
104 for i, s := range args {
109 if !strings.HasPrefix(s, "-") {
114 if strings.HasPrefix(s, "F") {
116 log.Printf("ignoring -F %q", from) // todo
121 // Ignore options otherwise.
122 // todo: we may want to parse more flags. some invocations may not be about sending a message. for now, we'll assume sendmail is only invoked to send a message.
126 // todo: perhaps allow configuration of config file through environment variable? have to keep in mind that mox with setgid moxsubmit would be reading the file.
127 const confPath = "/etc/moxsubmit.conf"
128 err := sconf.ParseFile(confPath, &submitconf)
129 xcheckf(err, "parsing config")
132 if len(args) == 1 && !tflag {
134 if !strings.Contains(recipient, "@") {
135 if submitconf.DefaultDestination == "" {
136 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
138 recipient = submitconf.DefaultDestination
140 _, err := smtp.ParseAddress(args[0])
141 xcheckf(err, "parsing recipient address")
143 } else if !tflag || len(args) != 0 {
144 log.Fatalln("need either exactly 1 recipient, or -t")
147 // Read message and build message we are going to send. We replace \n
148 // with \r\n, and we replace the From header.
149 // todo: should we also wrap lines that are too long? perhaps only if this is just text, no multipart?
150 var sb strings.Builder
151 r := bufio.NewReader(os.Stdin)
152 header := true // Whether we are in the header.
153 fmt.Fprintf(&sb, "From: <%s>\r\n", submitconf.From)
156 line, err := r.ReadString('\n')
157 if err != nil && err != io.EOF {
158 xcheckf(err, "reading message")
161 if !strings.HasSuffix(line, "\n") {
164 if !strings.HasSuffix(line, "\r\n") {
165 line = line[:len(line)-1] + "\r\n"
167 if header && line == "\r\n" {
168 // Bare \r\n marks end of header.
170 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
172 if submitconf.RequireTLS == RequireTLSNo {
173 line = "TLS-Required: No\r\n" + line
177 t := strings.SplitN(line, ":", 2)
179 log.Fatalf("invalid message, missing colon in header")
181 k := strings.ToLower(t[0])
183 // We already added a From header.
188 } else if tflag && k == "to" {
190 log.Fatalf("only single To header allowed")
192 s := strings.TrimSpace(t[1])
193 if !strings.Contains(s, "@") {
194 if submitconf.DefaultDestination == "" {
195 log.Fatalf("recipient %q has no @ and no default destination is configured", s)
197 recipient = submitconf.DefaultDestination
199 addrs, err := mail.ParseAddressList(s)
200 xcheckf(err, "parsing To address list")
202 log.Fatalf("only single address allowed in To header")
204 recipient = addrs[0].Address
217 if header && submitconf.RequireTLS == RequireTLSNo {
218 sb.WriteString("TLS-Required: No\r\n")
223 log.Fatalf("no recipient")
226 // Message seems acceptable. We'll try to deliver it from here. If that fails, we
227 // store the message in the users home directory.
228 // Must only use xsavecheckf for error checking in the code below.
230 xsavecheckf := func(err error, format string, args ...any) {
234 log.Printf("submit failed: %s: %s", fmt.Sprintf(format, args...), err)
235 homedir, err := os.UserHomeDir()
236 xcheckf(err, "finding homedir for storing message after failed delivery")
237 maildir := filepath.Join(homedir, "moxsubmit.failures")
238 os.Mkdir(maildir, 0700)
239 f, err := os.CreateTemp(maildir, "newmsg.")
240 xcheckf(err, "creating temp file for storing message after failed delivery")
241 // note: not removing the partial file if writing/closing below fails.
242 _, err = f.Write([]byte(msg))
243 xcheckf(err, "writing message to temp file after failed delivery")
246 xcheckf(err, "closing message in temp file after failed delivery")
248 log.Printf("saved message in %s", name)
252 addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port))
253 d := net.Dialer{Timeout: 30 * time.Second}
254 conn, err := d.Dial("tcp", addr)
255 xsavecheckf(err, "dial submit server")
257 auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
258 // Check explicitly configured mechanisms.
259 switch submitconf.AuthMethod {
260 case "SCRAM-SHA-256-PLUS":
262 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
264 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
265 case "SCRAM-SHA-256":
266 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, false), nil
267 case "SCRAM-SHA-1-PLUS":
269 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
271 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
273 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
275 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
277 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
280 // Try the defaults, from more to less secure.
281 if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
282 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
283 } else if slices.Contains(mechanisms, "SCRAM-SHA-256") {
284 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, true), nil
285 } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-1-PLUS") {
286 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
287 } else if slices.Contains(mechanisms, "SCRAM-SHA-1") {
288 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, true), nil
289 } else if slices.Contains(mechanisms, "CRAM-MD5") {
290 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
291 } else if slices.Contains(mechanisms, "PLAIN") {
292 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
294 // No mutually supported mechanism.
298 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
300 tlsMode := smtpclient.TLSSkip
303 tlsMode = smtpclient.TLSImmediate
305 } else if submitconf.STARTTLS {
306 tlsMode = smtpclient.TLSRequiredStartTLS
308 } else if submitconf.RequireTLS == RequireTLSYes {
309 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
312 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
313 xsavecheckf(err, "parsing our local hostname")
315 var remoteHostname dns.Domain
316 if net.ParseIP(submitconf.Host) == nil {
317 remoteHostname, err = dns.ParseDomain(submitconf.Host)
318 xsavecheckf(err, "parsing remote hostname")
321 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
322 opts := smtpclient.Opts{
324 RootCAs: mox.Conf.Static.TLS.CertPool,
326 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
327 xsavecheckf(err, "open smtp session")
329 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
330 xsavecheckf(err, "submit message")
332 if err := client.Close(); err != nil {
333 log.Printf("closing smtp session after message was sent: %v", err)