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.Printf("xxx, leftover data %q", rest)
171 log.Fatalf("leftover data in pem file")
172 } else if block == nil {
173 break
174 }
175 switch block.Type {
176 case "CERTIFICATE":
177 c, err := x509.ParseCertificate(block.Bytes)
178 xcheckf(err, "parsing certificate")
179 if cert.Leaf == nil {
180 cert.Leaf = c
181 }
182 cert.Certificate = append(cert.Certificate, block.Bytes)
183 case "PRIVATE KEY":
184 if cert.PrivateKey != nil {
185 log.Fatalf("cannot handle multiple private keys")
186 }
187 privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
188 xcheckf(err, "parsing private key")
189 cert.PrivateKey = privKey
190 default:
191 log.Fatalf("unrecognized pem type %q, only CERTIFICATE and PRIVATE KEY allowed", block.Type)
192 }
193 pemBuf = rest
194 }
195 if len(cert.Certificate) == 0 {
196 log.Fatalf("no certificate(s) found in pem file")
197 }
198 if cert.PrivateKey == nil {
199 log.Fatalf("no private key found in pem file")
200 }
201 type cryptoPublicKey interface {
202 Equal(x crypto.PublicKey) bool
203 }
204 if !cert.PrivateKey.(crypto.Signer).Public().(cryptoPublicKey).Equal(cert.Leaf.PublicKey) {
205 log.Fatalf("certificate public key does not match with private key")
206 }
207 submitconf.clientCert = &cert
208 }
209
210 var recipient string
211 if len(args) == 1 && !tflag {
212 recipient = args[0]
213 if !strings.Contains(recipient, "@") {
214 if submitconf.DefaultDestination == "" {
215 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
216 }
217 recipient = submitconf.DefaultDestination
218 } else {
219 _, err := smtp.ParseAddress(args[0])
220 xcheckf(err, "parsing recipient address")
221 }
222 } else if !tflag || len(args) != 0 {
223 log.Fatalln("need either exactly 1 recipient, or -t")
224 }
225
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)
233 var haveTo bool
234 for {
235 line, err := r.ReadString('\n')
236 if err != nil && err != io.EOF {
237 xcheckf(err, "reading message")
238 }
239 if line != "" {
240 if !strings.HasSuffix(line, "\n") {
241 line += "\n"
242 }
243 if !strings.HasSuffix(line, "\r\n") {
244 line = line[:len(line)-1] + "\r\n"
245 }
246 if header && line == "\r\n" {
247 // Bare \r\n marks end of header.
248 if !haveTo {
249 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
250 }
251 if submitconf.RequireTLS == RequireTLSNo {
252 line = "TLS-Required: No\r\n" + line
253 }
254 header = false
255 } else if header {
256 t := strings.SplitN(line, ":", 2)
257 if len(t) != 2 {
258 log.Fatalf("invalid message, missing colon in header")
259 }
260 k := strings.ToLower(t[0])
261 if k == "from" {
262 // We already added a From header.
263 if err == io.EOF {
264 break
265 }
266 continue
267 } else if tflag && k == "to" {
268 if recipient != "" {
269 log.Fatalf("only single To header allowed")
270 }
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)
275 }
276 recipient = submitconf.DefaultDestination
277 } else {
278 addrs, err := message.ParseAddressList(s)
279 xcheckf(err, "parsing To address list")
280 if len(addrs) != 1 {
281 log.Fatalf("only single address allowed in To header")
282 }
283 recipient = addrs[0].User + "@" + addrs[0].Host
284 }
285 }
286 if k == "to" {
287 haveTo = true
288 }
289 }
290 sb.WriteString(line)
291 }
292 if err == io.EOF {
293 break
294 }
295 }
296 if header && submitconf.RequireTLS == RequireTLSNo {
297 sb.WriteString("TLS-Required: No\r\n")
298 }
299 msg := sb.String()
300
301 if recipient == "" {
302 log.Fatalf("no recipient")
303 }
304
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.
308
309 xsavecheckf := func(err error, format string, args ...any) {
310 if err == nil {
311 return
312 }
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")
323 name := f.Name()
324 err = f.Close()
325 xcheckf(err, "closing message in temp file after failed delivery")
326 f = nil
327 log.Printf("saved message in %s", name)
328 os.Exit(1)
329 }
330
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")
335
336 auth := func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error) {
337 // Check explicitly configured mechanisms.
338 switch submitconf.AuthMethod {
339 case "EXTERNAL":
340 return sasl.NewClientExternal(submitconf.Username), nil
341 case "SCRAM-SHA-256-PLUS":
342 if cs == nil {
343 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
344 }
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":
349 if cs == nil {
350 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
351 }
352 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
353 case "SCRAM-SHA-1":
354 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
355 case "CRAM-MD5":
356 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
357 case "PLAIN":
358 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
359 }
360
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
376 }
377 // No mutually supported mechanism.
378 return nil, nil
379 }
380
381 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
382 defer cancel()
383 tlsMode := smtpclient.TLSSkip
384 tlsPKIX := false
385 if submitconf.TLS {
386 tlsMode = smtpclient.TLSImmediate
387 tlsPKIX = true
388 } else if submitconf.STARTTLS {
389 tlsMode = smtpclient.TLSRequiredStartTLS
390 tlsPKIX = true
391 } else if submitconf.RequireTLS == RequireTLSYes {
392 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
393 }
394 if submitconf.TLSInsecureSkipVerify {
395 tlsPKIX = false
396 }
397
398 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
399 xsavecheckf(err, "parsing our local hostname")
400
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")
405 }
406
407 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
408 opts := smtpclient.Opts{
409 Auth: auth,
410 RootCAs: mox.Conf.Static.TLS.CertPool,
411 ClientCert: submitconf.clientCert,
412 }
413 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
414 xsavecheckf(err, "open smtp session")
415
416 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
417 xsavecheckf(err, "submit message")
418
419 if err := client.Close(); err != nil {
420 log.Printf("closing smtp session after message was sent: %v", err)
421 }
422}
423
424func xminimalCert(privKey ed25519.PrivateKey) ([]byte, tls.Certificate) {
425 template := &x509.Certificate{
426 // Required field.
427 SerialNumber: big.NewInt(time.Now().Unix()),
428 }
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},
435 PrivateKey: privKey,
436 Leaf: cert,
437 }
438 return certBuf, c
439}
440