8 cryptorand "crypto/rand"
25 "github.com/mjl-/sconf"
27 "github.com/mjl-/mox/dns"
28 "github.com/mjl-/mox/message"
29 "github.com/mjl-/mox/mox-"
30 "github.com/mjl-/mox/sasl"
31 "github.com/mjl-/mox/smtp"
32 "github.com/mjl-/mox/smtpclient"
35var submitconf struct {
36 LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
37 Host string `sconf-doc:"Host to dial for delivery, e.g. mail.<domain>."`
38 Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
39 TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
40 STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
41 TLSInsecureSkipVerify bool `sconf:"optional" sconf-doc:"If true, do not verify the server TLS identity."`
42 Username string `sconf-doc:"For SMTP authentication."`
43 Password string `sconf:"optional" sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256-PLUS, CRAM-MD5, PLAIN."`
44 ClientAuthEd25519PrivateKey string `sconf:"optional" sconf-doc:"If set, used for TLS client authentication with a certificate. The private key must be a raw-url-base64-encoded ed25519 key. A basic certificate is composed automatically. The server must use the public key of a certificate to identify/verify users."`
45 ClientAuthCertPrivateKeyPEMFile string `sconf:"optional" sconf-doc:"If set, an absolute path to a PEM file containing both a PKCS#8 unencrypted private key and a certificate. Used for TLS client authentication."`
46 AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. EXTERNAL (for TLS client authentication), 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. Exactly one of Password, ClientAuthEd25519PrivateKey and ClientAuthCertPrivateKeyPEMFile must be set."`
47 From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
48 DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
49 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."`
51 // For TLS client authentication with a certificate. Either from
52 // ClientAuthEd25519PrivateKey or ClientAuthCertPrivateKeyPEMFile.
53 clientCert *tls.Certificate
56type RequireTLSOption string
59 RequireTLSDefault RequireTLSOption = ""
60 RequireTLSYes RequireTLSOption = "yes"
61 RequireTLSNo RequireTLSOption = "no"
64func cmdConfigDescribeSendmail(c *cmd) {
65 c.params = ">/etc/moxsubmit.conf"
66 c.help = `Describe configuration for mox when invoked as sendmail.`
67 if len(c.Parse()) != 0 {
71 err := sconf.Describe(os.Stdout, submitconf)
72 xcheckf(err, "describe config")
75func cmdSendmail(c *cmd) {
76 c.params = "[-Fname] [ignoredflags] [-t] [<message]"
77 c.help = `Sendmail is a drop-in replacement for /usr/sbin/sendmail to deliver emails sent by unix processes like cron.
79If invoked as "sendmail", it will act as sendmail for sending messages. Its
80intention is to let processes like cron send emails. Messages are submitted to
81an actual mail server over SMTP. The destination mail server and credentials are
82configured in /etc/moxsubmit.conf, see mox config describe-sendmail. The From
83message header is rewritten to the configured address. When the addressee
84appears to be a local user, because without @, the message is sent to the
85configured default address.
87If submitting an email fails, it is added to a directory moxsubmit.failures in
88the user's home directory.
90Most flags are ignored to fake compatibility with other sendmail
91implementations. A single recipient or the -t flag with a To-header is required.
92With the -t flag, Cc and Bcc headers are not handled specially, so Bcc is not
93removed and the addresses do not receive the email.
95/etc/moxsubmit.conf should be group-readable and not readable by others and this
96binary should be setgid that group:
99 install -m 2755 -o root -g moxsubmit mox /usr/sbin/sendmail
100 touch /etc/moxsubmit.conf
101 chown root:moxsubmit /etc/moxsubmit.conf
102 chmod 640 /etc/moxsubmit.conf
103 # edit /etc/moxsubmit.conf
106 // We are faking that we parse flags, this is non-standard, we want to be lax and ignore most flags.
108 c.flagArgs = []string{}
109 c.Parse() // We still have to call Parse for the usage gathering.
111 // Typical cron usage of sendmail:
112 // anacron: https://salsa.debian.org/debian/anacron/-/blob/c939c8c80fc9419c11a5e6be5cbe84f03ad332fd/runjob.c#L183
113 // cron: https://github.com/vixie/cron/blob/fea7a6c5421f88f034be8eef66a84d8b65b5fbe0/config.h#L41
116 var tflag bool // If set, we need to take the recipient(s) from the message headers. We only do one recipient, in To.
118 for i, s := range args {
123 if !strings.HasPrefix(s, "-") {
128 if strings.HasPrefix(s, "F") {
130 log.Printf("ignoring -F %q", from) // todo
135 // Ignore options otherwise.
136 // 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.
140 // 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.
141 const confPath = "/etc/moxsubmit.conf"
142 err := sconf.ParseFile(confPath, &submitconf)
143 xcheckf(err, "parsing config")
146 for _, s := range []string{submitconf.Password, submitconf.ClientAuthEd25519PrivateKey, submitconf.ClientAuthCertPrivateKeyPEMFile} {
148 secrets = append(secrets, s)
151 if len(secrets) != 1 {
152 xcheckf(fmt.Errorf("got passwords/keys %s, need exactly one", strings.Join(secrets, ", ")), "checking passwords/keys")
154 if submitconf.ClientAuthEd25519PrivateKey != "" {
155 seed, err := base64.RawURLEncoding.DecodeString(submitconf.ClientAuthEd25519PrivateKey)
156 xcheckf(err, "parsing ed25519 private key")
157 if len(seed) != ed25519.SeedSize {
158 xcheckf(fmt.Errorf("got %d bytes, need %d", len(seed), ed25519.SeedSize), "parsing ed25519 private key")
160 privKey := ed25519.NewKeyFromSeed(seed)
161 _, cert := xminimalCert(privKey)
162 submitconf.clientCert = &cert
163 } else if submitconf.ClientAuthCertPrivateKeyPEMFile != "" {
164 pemBuf, err := os.ReadFile(submitconf.ClientAuthCertPrivateKeyPEMFile)
165 xcheckf(err, "reading pem file")
166 var cert tls.Certificate
168 block, rest := pem.Decode(pemBuf)
169 if block == nil && len(rest) != 0 {
170 log.Printf("xxx, leftover data %q", rest)
171 log.Fatalf("leftover data in pem file")
172 } else if block == nil {
177 c, err := x509.ParseCertificate(block.Bytes)
178 xcheckf(err, "parsing certificate")
179 if cert.Leaf == nil {
182 cert.Certificate = append(cert.Certificate, block.Bytes)
184 if cert.PrivateKey != nil {
185 log.Fatalf("cannot handle multiple private keys")
187 privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
188 xcheckf(err, "parsing private key")
189 cert.PrivateKey = privKey
191 log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type)
195 if len(cert.Certificate) == 0 {
196 log.Fatalf("no certificate(s) found in pem file")
198 if cert.PrivateKey == nil {
199 log.Fatalf("no private key found in pem file")
201 type cryptoPublicKey interface {
202 Equal(x crypto.PublicKey) bool
204 if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) {
205 log.Fatalf("certificate public key does not match with private key")
207 submitconf.clientCert = &cert
211 if len(args) == 1 && !tflag {
213 if !strings.Contains(recipient, "@") {
214 if submitconf.DefaultDestination == "" {
215 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
217 recipient = submitconf.DefaultDestination
219 _, err := smtp.ParseAddress(args[0])
220 xcheckf(err, "parsing recipient address")
222 } else if !tflag || len(args) != 0 {
223 log.Fatalln("need either exactly 1 recipient, or -t")
226 // Read message and build message we are going to send. We replace \n
227 // with \r\n, and we replace the From header.
228 // todo: should we also wrap lines that are too long? perhaps only if this is just text, no multipart?
229 var sb strings.Builder
230 r := bufio.NewReader(os.Stdin)
231 header := true // Whether we are in the header.
232 fmt.Fprintf(&sb, "From: <%s>\r\n", submitconf.From)
235 line, err := r.ReadString('\n')
236 if err != nil && err != io.EOF {
237 xcheckf(err, "reading message")
240 if !strings.HasSuffix(line, "\n") {
243 if !strings.HasSuffix(line, "\r\n") {
244 line = line[:len(line)-1] + "\r\n"
246 if header && line == "\r\n" {
247 // Bare \r\n marks end of header.
249 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
251 if submitconf.RequireTLS == RequireTLSNo {
252 line = "TLS-Required: No\r\n" + line
256 t := strings.SplitN(line, ":", 2)
258 log.Fatalf("invalid message, missing colon in header")
260 k := strings.ToLower(t[0])
262 // We already added a From header.
267 } else if tflag && k == "to" {
269 log.Fatalf("only single To header allowed")
271 s := strings.TrimSpace(t[1])
272 if !strings.Contains(s, "@") {
273 if submitconf.DefaultDestination == "" {
274 log.Fatalf("recipient %q has no @ and no default destination is configured", s)
276 recipient = submitconf.DefaultDestination
278 addrs, err := message.ParseAddressList(s)
279 xcheckf(err, "parsing To address list")
281 log.Fatalf("only single address allowed in To header")
283 recipient = addrs[0].User + "@" + addrs[0].Host
296 if header && submitconf.RequireTLS == RequireTLSNo {
297 sb.WriteString("TLS-Required: No\r\n")
302 log.Fatalf("no recipient")
305 // Message seems acceptable. We'll try to deliver it from here. If that fails, we
306 // store the message in the users home directory.
307 // Must only use xsavecheckf for error checking in the code below.
309 xsavecheckf := func(err error, format string, args ...any) {
313 log.Printf("submit failed: %s: %s", fmt.Sprintf(format, args...), err)
314 homedir, err := os.UserHomeDir()
315 xcheckf(err, "finding homedir for storing message after failed delivery")
316 maildir := filepath.Join(homedir, "moxsubmit.failures")
317 os.Mkdir(maildir, 0700)
318 f, err := os.CreateTemp(maildir, "newmsg.")
319 xcheckf(err, "creating temp file for storing message after failed delivery")
320 // note: not removing the partial file if writing/closing below fails.
321 _, err = f.Write([]byte(msg))
322 xcheckf(err, "writing message to temp file after failed delivery")
325 xcheckf(err, "closing message in temp file after failed delivery")
327 log.Printf("saved message in %s", name)
331 addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port))
332 d := net.Dialer{Timeout: 30 * time.Second}
333 conn, err := d.Dial("tcp", addr)
334 xsavecheckf(err, "dial submit server")
336 auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
337 // Check explicitly configured mechanisms.
338 switch submitconf.AuthMethod {
340 return sasl.NewClientExternal(submitconf.Username), nil
341 case "SCRAM-SHA-256-PLUS":
343 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
345 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
346 case "SCRAM-SHA-256":
347 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, false), nil
348 case "SCRAM-SHA-1-PLUS":
350 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
352 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
354 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
356 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
358 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
361 // Try the defaults, from more to less secure.
362 if cs != nil && submitconf.clientCert != nil {
363 return sasl.NewClientExternal(submitconf.Username), nil
364 } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
365 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
366 } else if slices.Contains(mechanisms, "SCRAM-SHA-256") {
367 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, true), nil
368 } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-1-PLUS") {
369 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
370 } else if slices.Contains(mechanisms, "SCRAM-SHA-1") {
371 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, true), nil
372 } else if slices.Contains(mechanisms, "CRAM-MD5") {
373 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
374 } else if slices.Contains(mechanisms, "PLAIN") {
375 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
377 // No mutually supported mechanism.
381 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
383 tlsMode := smtpclient.TLSSkip
386 tlsMode = smtpclient.TLSImmediate
388 } else if submitconf.STARTTLS {
389 tlsMode = smtpclient.TLSRequiredStartTLS
391 } else if submitconf.RequireTLS == RequireTLSYes {
392 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
394 if submitconf.TLSInsecureSkipVerify {
398 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
399 xsavecheckf(err, "parsing our local hostname")
401 var remoteHostname dns.Domain
402 if net.ParseIP(submitconf.Host) == nil {
403 remoteHostname, err = dns.ParseDomain(submitconf.Host)
404 xsavecheckf(err, "parsing remote hostname")
407 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
408 opts := smtpclient.Opts{
410 RootCAs: mox.Conf.Static.TLS.CertPool,
411 ClientCert: submitconf.clientCert,
413 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
414 xsavecheckf(err, "open smtp session")
416 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
417 xsavecheckf(err, "submit message")
419 if err := client.Close(); err != nil {
420 log.Printf("closing smtp session after message was sent: %v", err)
424func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) {
425 template := &x509.Certificate{
427 SerialNumber: big.NewInt(time.Now().Unix()),
429 certBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
430 xcheckf(err, "creating minimal certificate")
431 cert, err := x509.ParseCertificate(certBuf)
432 xcheckf(err, "parsing certificate")
433 c := tls.Certificate{
434 Certificate: [][]byte{certBuf},