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.Fatalf("leftover data in pem file")
171 } else if block == nil {
176 c, err := x509.ParseCertificate(block.Bytes)
177 xcheckf(err, "parsing certificate")
178 if cert.Leaf == nil {
181 cert.Certificate = append(cert.Certificate, block.Bytes)
183 if cert.PrivateKey != nil {
184 log.Fatalf("cannot handle multiple private keys")
186 privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
187 xcheckf(err, "parsing private key")
188 cert.PrivateKey = privKey
190 log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type)
194 if len(cert.Certificate) == 0 {
195 log.Fatalf("no certificate(s) found in pem file")
197 if cert.PrivateKey == nil {
198 log.Fatalf("no private key found in pem file")
200 type cryptoPublicKey interface {
201 Equal(x crypto.PublicKey) bool
203 if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) {
204 log.Fatalf("certificate public key does not match with private key")
206 submitconf.clientCert = &cert
210 if len(args) == 1 && !tflag {
212 if !strings.Contains(recipient, "@") {
213 if submitconf.DefaultDestination == "" {
214 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
216 recipient = submitconf.DefaultDestination
218 _, err := smtp.ParseAddress(args[0])
219 xcheckf(err, "parsing recipient address")
221 } else if !tflag || len(args) != 0 {
222 log.Fatalln("need either exactly 1 recipient, or -t")
225 // Read message and build message we are going to send. We replace \n
226 // with \r\n, and we replace the From header.
227 // todo: should we also wrap lines that are too long? perhaps only if this is just text, no multipart?
228 var sb strings.Builder
229 r := bufio.NewReader(os.Stdin)
230 header := true // Whether we are in the header.
231 fmt.Fprintf(&sb, "From: <%s>\r\n", submitconf.From)
234 line, err := r.ReadString('\n')
235 if err != nil && err != io.EOF {
236 xcheckf(err, "reading message")
239 if !strings.HasSuffix(line, "\n") {
242 if !strings.HasSuffix(line, "\r\n") {
243 line = line[:len(line)-1] + "\r\n"
245 if header && line == "\r\n" {
246 // Bare \r\n marks end of header.
248 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
250 if submitconf.RequireTLS == RequireTLSNo {
251 line = "TLS-Required: No\r\n" + line
255 t := strings.SplitN(line, ":", 2)
257 log.Fatalf("invalid message, missing colon in header")
259 k := strings.ToLower(t[0])
261 // We already added a From header.
266 } else if tflag && k == "to" {
268 log.Fatalf("only single To header allowed")
270 s := strings.TrimSpace(t[1])
271 if !strings.Contains(s, "@") {
272 if submitconf.DefaultDestination == "" {
273 log.Fatalf("recipient %q has no @ and no default destination is configured", s)
275 recipient = submitconf.DefaultDestination
277 addrs, err := message.ParseAddressList(s)
278 xcheckf(err, "parsing To address list")
280 log.Fatalf("only single address allowed in To header")
282 recipient = addrs[0].User + "@" + addrs[0].Host
295 if header && submitconf.RequireTLS == RequireTLSNo {
296 sb.WriteString("TLS-Required: No\r\n")
301 log.Fatalf("no recipient")
304 // Message seems acceptable. We'll try to deliver it from here. If that fails, we
305 // store the message in the users home directory.
306 // Must only use xsavecheckf for error checking in the code below.
308 xsavecheckf := func(err error, format string, args ...any) {
312 log.Printf("submit failed: %s: %s", fmt.Sprintf(format, args...), err)
313 homedir, err := os.UserHomeDir()
314 xcheckf(err, "finding homedir for storing message after failed delivery")
315 maildir := filepath.Join(homedir, "moxsubmit.failures")
316 os.Mkdir(maildir, 0700) // Exists is no problem, failure is found during create.
317 f, err := os.CreateTemp(maildir, "newmsg.")
318 xcheckf(err, "creating temp file for storing message after failed delivery")
319 // note: not removing the partial file if writing/closing below fails.
320 _, err = f.Write([]byte(msg))
321 xcheckf(err, "writing message to temp file after failed delivery")
324 xcheckf(err, "closing message in temp file after failed delivery")
326 log.Printf("saved message in %s", name)
330 addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port))
331 d := net.Dialer{Timeout: 30 * time.Second}
332 conn, err := d.Dial("tcp", addr)
333 xsavecheckf(err, "dial submit server")
335 auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
336 // Check explicitly configured mechanisms.
337 switch submitconf.AuthMethod {
339 return sasl.NewClientExternal(submitconf.Username), nil
340 case "SCRAM-SHA-256-PLUS":
342 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
344 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
345 case "SCRAM-SHA-256":
346 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, false), nil
347 case "SCRAM-SHA-1-PLUS":
349 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
351 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
353 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
355 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
357 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
360 // Try the defaults, from more to less secure.
361 if cs != nil && submitconf.clientCert != nil {
362 return sasl.NewClientExternal(submitconf.Username), nil
363 } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-256-PLUS") {
364 return sasl.NewClientSCRAMSHA256PLUS(submitconf.Username, submitconf.Password, *cs), nil
365 } else if slices.Contains(mechanisms, "SCRAM-SHA-256") {
366 return sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password, true), nil
367 } else if cs != nil && slices.Contains(mechanisms, "SCRAM-SHA-1-PLUS") {
368 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
369 } else if slices.Contains(mechanisms, "SCRAM-SHA-1") {
370 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, true), nil
371 } else if slices.Contains(mechanisms, "CRAM-MD5") {
372 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
373 } else if slices.Contains(mechanisms, "PLAIN") {
374 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
376 // No mutually supported mechanism.
380 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
382 tlsMode := smtpclient.TLSSkip
385 tlsMode = smtpclient.TLSImmediate
387 } else if submitconf.STARTTLS {
388 tlsMode = smtpclient.TLSRequiredStartTLS
390 } else if submitconf.RequireTLS == RequireTLSYes {
391 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
393 if submitconf.TLSInsecureSkipVerify {
397 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
398 xsavecheckf(err, "parsing our local hostname")
400 var remoteHostname dns.Domain
401 if net.ParseIP(submitconf.Host) == nil {
402 remoteHostname, err = dns.ParseDomain(submitconf.Host)
403 xsavecheckf(err, "parsing remote hostname")
406 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
407 opts := smtpclient.Opts{
409 RootCAs: mox.Conf.Static.TLS.CertPool,
410 ClientCert: submitconf.clientCert,
412 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
413 xsavecheckf(err, "open smtp session")
415 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
416 xsavecheckf(err, "submit message")
418 if err := client.Close(); err != nil {
419 log.Printf("closing smtp session after message was sent: %v", err)
423func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) {
424 template := &x509.Certificate{
426 SerialNumber: big.NewInt(time.Now().Unix()),
428 certBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
429 xcheckf(err, "creating minimal certificate")
430 cert, err := x509.ParseCertificate(certBuf)
431 xcheckf(err, "parsing certificate")
432 c := tls.Certificate{
433 Certificate: [][]byte{certBuf},