1package main
2
3import (
4 "bufio"
5 "context"
6 "crypto/tls"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net"
12 "os"
13 "path/filepath"
14 "slices"
15 "strings"
16 "time"
17
18 "github.com/mjl-/sconf"
19
20 "github.com/mjl-/mox/dns"
21 "github.com/mjl-/mox/message"
22 "github.com/mjl-/mox/mox-"
23 "github.com/mjl-/mox/sasl"
24 "github.com/mjl-/mox/smtp"
25 "github.com/mjl-/mox/smtpclient"
26)
27
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."`
40}
41
42type RequireTLSOption string
43
44const (
45 RequireTLSDefault RequireTLSOption = ""
46 RequireTLSYes RequireTLSOption = "yes"
47 RequireTLSNo RequireTLSOption = "no"
48)
49
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 {
54 c.Usage()
55 }
56
57 err := sconf.Describe(os.Stdout, submitconf)
58 xcheckf(err, "describe config")
59}
60
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.
64
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.
72
73If submitting an email fails, it is added to a directory moxsubmit.failures in
74the user's home directory.
75
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.
80
81/etc/moxsubmit.conf should be group-readable and not readable by others and this
82binary should be setgid that group:
83
84 groupadd moxsubmit
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
90`
91
92 // We are faking that we parse flags, this is non-standard, we want to be lax and ignore most flags.
93 args := c.flagArgs
94 c.flagArgs = []string{}
95 c.Parse() // We still have to call Parse for the usage gathering.
96
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
100
101 var from string
102 var tflag bool // If set, we need to take the recipient(s) from the message headers. We only do one recipient, in To.
103 o := 0
104 for i, s := range args {
105 if s == "--" {
106 o = i + 1
107 break
108 }
109 if !strings.HasPrefix(s, "-") {
110 o = i
111 break
112 }
113 s = s[1:]
114 if strings.HasPrefix(s, "F") {
115 from = s[1:]
116 log.Printf("ignoring -F %q", from) // todo
117 } else if s == "t" {
118 tflag = true
119 }
120 o = i + 1
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.
123 }
124 args = args[o:]
125
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")
130
131 var recipient string
132 if len(args) == 1 && !tflag {
133 recipient = args[0]
134 if !strings.Contains(recipient, "@") {
135 if submitconf.DefaultDestination == "" {
136 log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
137 }
138 recipient = submitconf.DefaultDestination
139 } else {
140 _, err := smtp.ParseAddress(args[0])
141 xcheckf(err, "parsing recipient address")
142 }
143 } else if !tflag || len(args) != 0 {
144 log.Fatalln("need either exactly 1 recipient, or -t")
145 }
146
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)
154 var haveTo bool
155 for {
156 line, err := r.ReadString('\n')
157 if err != nil && err != io.EOF {
158 xcheckf(err, "reading message")
159 }
160 if line != "" {
161 if !strings.HasSuffix(line, "\n") {
162 line += "\n"
163 }
164 if !strings.HasSuffix(line, "\r\n") {
165 line = line[:len(line)-1] + "\r\n"
166 }
167 if header && line == "\r\n" {
168 // Bare \r\n marks end of header.
169 if !haveTo {
170 line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
171 }
172 if submitconf.RequireTLS == RequireTLSNo {
173 line = "TLS-Required: No\r\n" + line
174 }
175 header = false
176 } else if header {
177 t := strings.SplitN(line, ":", 2)
178 if len(t) != 2 {
179 log.Fatalf("invalid message, missing colon in header")
180 }
181 k := strings.ToLower(t[0])
182 if k == "from" {
183 // We already added a From header.
184 if err == io.EOF {
185 break
186 }
187 continue
188 } else if tflag && k == "to" {
189 if recipient != "" {
190 log.Fatalf("only single To header allowed")
191 }
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)
196 }
197 recipient = submitconf.DefaultDestination
198 } else {
199 addrs, err := message.ParseAddressList(s)
200 xcheckf(err, "parsing To address list")
201 if len(addrs) != 1 {
202 log.Fatalf("only single address allowed in To header")
203 }
204 recipient = addrs[0].User + "@" + addrs[0].Host
205 }
206 }
207 if k == "to" {
208 haveTo = true
209 }
210 }
211 sb.WriteString(line)
212 }
213 if err == io.EOF {
214 break
215 }
216 }
217 if header && submitconf.RequireTLS == RequireTLSNo {
218 sb.WriteString("TLS-Required: No\r\n")
219 }
220 msg := sb.String()
221
222 if recipient == "" {
223 log.Fatalf("no recipient")
224 }
225
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.
229
230 xsavecheckf := func(err error, format string, args ...any) {
231 if err == nil {
232 return
233 }
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")
244 name := f.Name()
245 err = f.Close()
246 xcheckf(err, "closing message in temp file after failed delivery")
247 f = nil
248 log.Printf("saved message in %s", name)
249 os.Exit(1)
250 }
251
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")
256
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":
261 if cs == nil {
262 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
263 }
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":
268 if cs == nil {
269 return nil, fmt.Errorf("scram plus authentication mechanism requires tls")
270 }
271 return sasl.NewClientSCRAMSHA1PLUS(submitconf.Username, submitconf.Password, *cs), nil
272 case "SCRAM-SHA-1":
273 return sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password, false), nil
274 case "CRAM-MD5":
275 return sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password), nil
276 case "PLAIN":
277 return sasl.NewClientPlain(submitconf.Username, submitconf.Password), nil
278 }
279
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
293 }
294 // No mutually supported mechanism.
295 return nil, nil
296 }
297
298 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
299 defer cancel()
300 tlsMode := smtpclient.TLSSkip
301 tlsPKIX := false
302 if submitconf.TLS {
303 tlsMode = smtpclient.TLSImmediate
304 tlsPKIX = true
305 } else if submitconf.STARTTLS {
306 tlsMode = smtpclient.TLSRequiredStartTLS
307 tlsPKIX = true
308 } else if submitconf.RequireTLS == RequireTLSYes {
309 xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
310 }
311
312 ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
313 xsavecheckf(err, "parsing our local hostname")
314
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")
319 }
320
321 // todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
322 opts := smtpclient.Opts{
323 Auth: auth,
324 RootCAs: mox.Conf.Static.TLS.CertPool,
325 }
326 client, err := smtpclient.New(ctx, c.log.Logger, conn, tlsMode, tlsPKIX, ourHostname, remoteHostname, opts)
327 xsavecheckf(err, "open smtp session")
328
329 err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
330 xsavecheckf(err, "submit message")
331
332 if err := client.Close(); err != nil {
333 log.Printf("closing smtp session after message was sent: %v", err)
334 }
335}
336