1package main
2
3import (
4 "bufio"
5 "context"
6 "crypto"
7 "crypto/ed25519"
8 cryptorand "crypto/rand"
9 "crypto/tls"
10 "crypto/x509"
11 "encoding/base64"
12 "encoding/pem"
13 "errors"
14 "fmt"
15 "io"
16 "log"
17 "math/big"
18 "net"
19 "os"
20 "path/filepath"
21 "slices"
22 "strings"
23 "time"
24
25 "github.com/mjl-/sconf"
26
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"
33)
34
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."`
50
51 // For TLS client authentication with a certificate. Either from
52 // ClientAuthEd25519PrivateKey or ClientAuthCertPrivateKeyPEMFile.
53 clientCert *tls.Certificate
54}
55
56type RequireTLSOption string
57
58const (
59 RequireTLSDefault RequireTLSOption = ""
60 RequireTLSYes RequireTLSOption = "yes"
61 RequireTLSNo RequireTLSOption = "no"
62)
63
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 {
68 c.Usage()
69 }
70
71 err := sconf.Describe(os.Stdout, submitconf)
72 xcheckf(err, "describe config")
73}
74
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.
78
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.
86
87If submitting an email fails, it is added to a directory moxsubmit.failures in
88the user's home directory.
89
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.
94
95/etc/moxsubmit.conf should be group-readable and not readable by others and this
96binary should be setgid that group:
97
98 groupadd moxsubmit
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
104`
105
106 // We are faking that we parse flags, this is non-standard, we want to be lax and ignore most flags.
107 args := c.flagArgs
108 c.flagArgs = []string{}
109 c.Parse() // We still have to call Parse for the usage gathering.
110
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
114
115 var from string
116 var tflag bool // If set, we need to take the recipient(s) from the message headers. We only do one recipient, in To.
117 o := 0
118 for i, s := range args {
119 if s == "--" {
120 o = i + 1
121 break
122 }
123 if !strings.HasPrefix(s, "-") {
124 o = i
125 break
126 }
127 s = s[1:]
128 if strings.HasPrefix(s, "F") {
129 from = s[1:]
130 log.Printf("ignoring -F %q", from) // todo
131 } else if s == "t" {
132 tflag = true
133 }
134 o = i + 1
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.
137 }
138 args = args[o:]
139
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")
144
145 var secrets []string
146 for _, s := range []string{submitconf.Password, submitconf.ClientAuthEd25519PrivateKey, submitconf.ClientAuthCertPrivateKeyPEMFile} {
147 if s != "" {
148 secrets = append(secrets, s)
149 }
150 }
151 if len(secrets) != 1 {
152 xcheckf(fmt.Errorf("got passwords/keys %s, need exactly one", strings.Join(secrets, ", ")), "checking passwords/keys")
153 }
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")
159 }
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
167 for {
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 {
172 break
173 }
174 switch block.Type {
175 case "CERTIFICATE":
176 c, err := x509.ParseCertificate(block.Bytes)
177 xcheckf(err, "parsing certificate")
178 if cert.Leaf == nil {
179 cert.Leaf = c
180 }
181 cert.Certificate = append(cert.Certificate, block.Bytes)
182 case "PRIVATE KEY":
183 if cert.PrivateKey != nil {
184 log.Fatalf("cannot handle multiple private keys")
185 }
186 privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
187 xcheckf(err, "parsing private key")
188 cert.PrivateKey = privKey
189 default:
190 log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type)
191 }
192 pemBuf = rest
193 }
194 if len(cert.Certificate) == 0 {
195 log.Fatalf("no certificate(s) found in pem file")
196 }
197 if cert.PrivateKey == nil {
198 log.Fatalf("no private key found in pem file")
199 }
200 type cryptoPublicKey interface {
201 Equal(x crypto.PublicKey) bool
202 }
203 if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) {
204 log.Fatalf("certificate public key does not match with private key")
205 }
206 submitconf.clientCert = &cert
207 }
208
209 var recipient string
210 if len(args) == 1 && !tflag {
211 recipient = args[0]
212 if !strings.Contains(recipient, "@") {
213 if submitconf.DefaultDestination == "" {
214 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
215 }
216 recipient = submitconf.DefaultDestination
217 } else {
218 _, err := smtp.ParseAddress(args[0])
219 xcheckf(err, "parsing recipient address")
220 }
221 } else if !tflag || len(args) != 0 {
222 log.Fatalln("need either exactly 1 recipient, or -t")
223 }
224
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)
232 var haveTo bool
233 for {
234 line, err := r.ReadString('\n')
235 if err != nil && err != io.EOF {
236 xcheckf(err, "reading message")
237 }
238 if line != "" {
239 if !strings.HasSuffix(line, "\n") {
240 line += "\n"
241 }
242 if !strings.HasSuffix(line, "\r\n") {
243 line = line[:len(line)-1] + "\r\n"
244 }
245 if header && line == "\r\n" {
246 // Bare \r\n marks end of header.
247 if !haveTo {
248 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
249 }
250 if submitconf.RequireTLS == RequireTLSNo {
251 line = "TLS-Required: No\r\n" + line
252 }
253 header = false
254 } else if header {
255 t := strings.SplitN(line, ":", 2)
256 if len(t) != 2 {
257 log.Fatalf("invalid message, missing colon in header")
258 }
259 k := strings.ToLower(t[0])
260 if k == "from" {
261 // We already added a From header.
262 if err == io.EOF {
263 break
264 }
265 continue
266 } else if tflag && k == "to" {
267 if recipient != "" {
268 log.Fatalf("only single To header allowed")
269 }
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)
274 }
275 recipient = submitconf.DefaultDestination
276 } else {
277 addrs, err := message.ParseAddressList(s)
278 xcheckf(err, "parsing To address list")
279 if len(addrs) != 1 {
280 log.Fatalf("only single address allowed in To header")
281 }
282 recipient = addrs[0].User + "@" + addrs[0].Host
283 }
284 }
285 if k == "to" {
286 haveTo = true
287 }
288 }
289 sb.WriteString(line)
290 }
291 if err == io.EOF {
292 break
293 }
294 }
295 if header && submitconf.RequireTLS == RequireTLSNo {
296 sb.WriteString("TLS-Required: No\r\n")
297 }
298 msg := sb.String()
299
300 if recipient == "" {
301 log.Fatalf("no recipient")
302 }
303
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.
307
308 xsavecheckf := func(err error, format string, args ...any) {
309 if err == nil {
310 return
311 }
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")
322 name := f.Name()
323 err = f.Close()
324 xcheckf(err, "closing message in temp file after failed delivery")
325 f = nil
326 log.Printf("saved message in %s", name)
327 os.Exit(1)
328 }
329
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")
334
335 auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
336 // Check explicitly configured mechanisms.
337 switch submitconf.AuthMethod {
338 case "EXTERNAL":
339 return sasl.NewClientExternal(submitconf.Username), nil
340 case "SCRAM-SHA-256-PLUS":
341 if cs == nil {
342 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
343 }
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":
348 if cs == nil {
349 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
350 }
351 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
352 case "SCRAM-SHA-1":
353 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
354 case "CRAM-MD5":
355 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
356 case "PLAIN":
357 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
358 }
359
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
375 }
376 // No mutually supported mechanism.
377 return nil, nil
378 }
379
380 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
381 defer cancel()
382 tlsMode := smtpclient.TLSSkip
383 tlsPKIX := false
384 if submitconf.TLS {
385 tlsMode = smtpclient.TLSImmediate
386 tlsPKIX = true
387 } else if submitconf.STARTTLS {
388 tlsMode = smtpclient.TLSRequiredStartTLS
389 tlsPKIX = true
390 } else if submitconf.RequireTLS == RequireTLSYes {
391 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
392 }
393 if submitconf.TLSInsecureSkipVerify {
394 tlsPKIX = false
395 }
396
397 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
398 xsavecheckf(err, "parsing our local hostname")
399
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")
404 }
405
406 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
407 opts := smtpclient.Opts{
408 Auth: auth,
409 RootCAs: mox.Conf.Static.TLS.CertPool,
410 ClientCert: submitconf.clientCert,
411 }
412 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
413 xsavecheckf(err, "open smtp session")
414
415 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
416 xsavecheckf(err, "submit message")
417
418 if err := client.Close(); err != nil {
419 log.Printf("closing smtp session after message was sent: %v", err)
420 }
421}
422
423func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) {
424 template := &x509.Certificate{
425 // Required field.
426 SerialNumber: big.NewInt(time.Now().Unix()),
427 }
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},
434 PrivateKey: privKey,
435 Leaf: cert,
436 }
437 return certBuf, c
438}
439