mox/sendmail.go
Mechiel Lukkien 28fae96a9b
make mox compile on windows, without "mox serve" but with working "mox localserve"
getting mox to compile required changing code in only a few places where
package "syscall" was used: for accessing file access times and for umask
handling. an open problem is how to start a process as an unprivileged user on
windows.  that's why "mox serve" isn't implemented yet. and just finding a way
to implement it now may not be good enough in the near future: we may want to
starting using a more complete privilege separation approach, with a process
handling sensitive tasks (handling private keys, authentication), where we may
want to pass file descriptors between processes. how would that work on
windows?

anyway, getting mox to compile for windows doesn't mean it works properly on
windows. the largest issue: mox would normally open a file, rename or remove
it, and finally close it. this happens during message delivery. that doesn't
work on windows, the rename/remove would fail because the file is still open.
so this commit swaps many "remove" and "close" calls. renames are a longer
story: message delivery had two ways to deliver: with "consuming" the
(temporary) message file (which would rename it to its final destination), and
without consuming (by hardlinking the file, falling back to copying). the last
delivery to a recipient of a message (and the only one in the common case of a
single recipient) would consume the message, and the earlier recipients would
not.  during delivery, the already open message file was used, to parse the
message.  we still want to use that open message file, and the caller now stays
responsible for closing it, but we no longer try to rename (consume) the file.
we always hardlink (or copy) during delivery (this works on windows), and the
caller is responsible for closing and removing (in that order) the original
temporary file. this does cost one syscall more. but it makes the delivery code
(responsibilities) a bit simpler.

there is one more obvious issue: the file system path separator. mox already
used the "filepath" package to join paths in many places, but not everywhere.
and it still used strings with slashes for local file access. with this commit,
the code now uses filepath.FromSlash for path strings with slashes, uses
"filepath" in a few more places where it previously didn't. also switches from
"filepath" to regular "path" package when handling mailbox names in a few
places, because those always use forward slashes, regardless of local file
system conventions.  windows can handle forward slashes when opening files, so
test code that passes path strings with forward slashes straight to go stdlib
file i/o functions are left unchanged to reduce code churn. the regular
non-test code, or test code that uses path strings in places other than
standard i/o functions, does have the paths converted for consistent paths
(otherwise we would end up with paths with mixed forward/backward slashes in
log messages).

windows cannot dup a listening socket. for "mox localserve", it isn't
important, and we can work around the issue. the current approach for "mox
serve" (forking a process and passing file descriptors of listening sockets on
"privileged" ports) won't work on windows. perhaps it isn't needed on windows,
and any user can listen on "privileged" ports? that would be welcome.

on windows, os.Open cannot open a directory, so we cannot call Sync on it after
message delivery. a cursory internet search indicates that directories cannot
be synced on windows. the story is probably much more nuanced than that, with
long deep technical details/discussions/disagreement/confusion, like on unix.
for "mox localserve" we can get away with making syncdir a no-op.
2023-10-14 10:54:07 +02:00

286 lines
9.9 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"io"
"log"
"net"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/mjl-/sconf"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/sasl"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient"
)
var submitconf struct {
LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
Host string `sconf-doc:"Host to dial for delivery, e.g. mail.<domain>."`
Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
Username string `sconf-doc:"For SMTP authentication."`
Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5, PLAIN."`
AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256. If not set, any mutually supported algorithm can be used, in order of most to least secure."`
From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
}
func cmdConfigDescribeSendmail(c *cmd) {
c.params = ">/etc/moxsubmit.conf"
c.help = `Describe configuration for mox when invoked as sendmail.`
if len(c.Parse()) != 0 {
c.Usage()
}
err := sconf.Describe(os.Stdout, submitconf)
xcheckf(err, "describe config")
}
func cmdSendmail(c *cmd) {
c.params = "[-Fname] [ignoredflags] [-t] [<message]"
c.help = `Sendmail is a drop-in replacement for /usr/sbin/sendmail to deliver emails sent by unix processes like cron.
If invoked as "sendmail", it will act as sendmail for sending messages. Its
intention is to let processes like cron send emails. Messages are submitted to
an actual mail server over SMTP. The destination mail server and credentials are
configured in /etc/moxsubmit.conf, see mox config describe-sendmail. The From
message header is rewritten to the configured address. When the addressee
appears to be a local user, because without @, the message is sent to the
configured default address.
If submitting an email fails, it is added to a directory moxsubmit.failures in
the user's home directory.
Most flags are ignored to fake compatibility with other sendmail
implementations. A single recipient or the -t flag with a To-header is required.
With the -t flag, Cc and Bcc headers are not handled specially, so Bcc is not
removed and the addresses do not receive the email.
/etc/moxsubmit.conf should be group-readable and not readable by others and this
binary should be setgid that group:
groupadd moxsubmit
install -m 2755 -o root -g moxsubmit mox /usr/sbin/sendmail
touch /etc/moxsubmit.conf
chown root:moxsubmit /etc/moxsubmit.conf
chmod 640 /etc/moxsubmit.conf
# edit /etc/moxsubmit.conf
`
// We are faking that we parse flags, this is non-standard, we want to be lax and ignore most flags.
args := c.flagArgs
c.flagArgs = []string{}
c.Parse() // We still have to call Parse for the usage gathering.
// Typical cron usage of sendmail:
// anacron: https://salsa.debian.org/debian/anacron/-/blob/c939c8c80fc9419c11a5e6be5cbe84f03ad332fd/runjob.c#L183
// cron: https://github.com/vixie/cron/blob/fea7a6c5421f88f034be8eef66a84d8b65b5fbe0/config.h#L41
var from string
var tflag bool // If set, we need to take the recipient(s) from the message headers. We only do one recipient, in To.
o := 0
for i, s := range args {
if s == "--" {
o = i + 1
break
}
if !strings.HasPrefix(s, "-") {
o = i
break
}
s = s[1:]
if strings.HasPrefix(s, "F") {
from = s[1:]
log.Printf("ignoring -F %q", from) // todo
} else if s == "t" {
tflag = true
}
o = i + 1
// Ignore options otherwise.
// 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.
}
args = args[o:]
// 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.
const confPath = "/etc/moxsubmit.conf"
err := sconf.ParseFile(confPath, &submitconf)
xcheckf(err, "parsing config")
var recipient string
if len(args) == 1 && !tflag {
recipient = args[0]
if !strings.Contains(recipient, "@") {
if submitconf.DefaultDestination == "" {
log.Fatalf("recipient %q has no @ and no default destination configured", recipient)
}
recipient = submitconf.DefaultDestination
} else {
_, err := smtp.ParseAddress(args[0])
xcheckf(err, "parsing recipient address")
}
} else if !tflag || len(args) != 0 {
log.Fatalln("need either exactly 1 recipient, or -t")
}
// Read message and build message we are going to send. We replace \n
// with \r\n, and we replace the From header.
// todo: should we also wrap lines that are too long? perhaps only if this is just text, no multipart?
var sb strings.Builder
r := bufio.NewReader(os.Stdin)
header := true // Whether we are in the header.
fmt.Fprintf(&sb, "From: <%s>\r\n", submitconf.From)
var haveTo bool
for {
line, err := r.ReadString('\n')
if err != nil && err != io.EOF {
xcheckf(err, "reading message")
}
if line != "" {
if !strings.HasSuffix(line, "\n") {
line += "\n"
}
if !strings.HasSuffix(line, "\r\n") {
line = line[:len(line)-1] + "\r\n"
}
if header && line == "\r\n" {
// Bare \r\n marks end of header.
if !haveTo {
line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
}
header = false
} else if header {
t := strings.SplitN(line, ":", 2)
if len(t) != 2 {
log.Fatalf("invalid message, missing colon in header")
}
k := strings.ToLower(t[0])
if k == "from" {
// We already added a From header.
if err == io.EOF {
break
}
continue
} else if tflag && k == "to" {
if recipient != "" {
log.Fatalf("only single To header allowed")
}
s := strings.TrimSpace(t[1])
if !strings.Contains(s, "@") {
if submitconf.DefaultDestination == "" {
log.Fatalf("recipient %q has no @ and no default destination is configured", s)
}
recipient = submitconf.DefaultDestination
} else {
addrs, err := mail.ParseAddressList(s)
xcheckf(err, "parsing To address list")
if len(addrs) != 1 {
log.Fatalf("only single address allowed in To header")
}
recipient = addrs[0].Address
}
}
if k == "to" {
haveTo = true
}
}
sb.WriteString(line)
}
if err == io.EOF {
break
}
}
msg := sb.String()
if recipient == "" {
log.Fatalf("no recipient")
}
// Message seems acceptable. We'll try to deliver it from here. If that fails, we
// store the message in the users home directory.
// Must only use xsavecheckf for error checking in the code below.
xsavecheckf := func(err error, format string, args ...any) {
if err == nil {
return
}
log.Printf("submit failed: %s: %s", fmt.Sprintf(format, args...), err)
homedir, err := os.UserHomeDir()
xcheckf(err, "finding homedir for storing message after failed delivery")
maildir := filepath.Join(homedir, "moxsubmit.failures")
os.Mkdir(maildir, 0700)
f, err := os.CreateTemp(maildir, "newmsg.")
xcheckf(err, "creating temp file for storing message after failed delivery")
// note: not removing the partial file if writing/closing below fails.
_, err = f.Write([]byte(msg))
xcheckf(err, "writing message to temp file after failed delivery")
name := f.Name()
err = f.Close()
xcheckf(err, "closing message in temp file after failed delivery")
f = nil
log.Printf("saved message in %s", name)
os.Exit(1)
}
addr := net.JoinHostPort(submitconf.Host, fmt.Sprintf("%d", submitconf.Port))
d := net.Dialer{Timeout: 30 * time.Second}
conn, err := d.Dial("tcp", addr)
xsavecheckf(err, "dial submit server")
var auth []sasl.Client
switch submitconf.AuthMethod {
case "SCRAM-SHA-256":
auth = []sasl.Client{sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password)}
case "SCRAM-SHA-1":
auth = []sasl.Client{sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password)}
case "CRAM-MD5":
auth = []sasl.Client{sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password)}
case "PLAIN":
auth = []sasl.Client{sasl.NewClientPlain(submitconf.Username, submitconf.Password)}
default:
auth = []sasl.Client{
sasl.NewClientSCRAMSHA256(submitconf.Username, submitconf.Password),
sasl.NewClientSCRAMSHA1(submitconf.Username, submitconf.Password),
sasl.NewClientCRAMMD5(submitconf.Username, submitconf.Password),
sasl.NewClientPlain(submitconf.Username, submitconf.Password),
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
tlsMode := smtpclient.TLSSkip
if submitconf.TLS {
tlsMode = smtpclient.TLSStrictImmediate
} else if submitconf.STARTTLS {
tlsMode = smtpclient.TLSStrictStartTLS
}
ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
xsavecheckf(err, "parsing our local hostname")
var remoteHostname dns.Domain
if net.ParseIP(submitconf.Host) == nil {
remoteHostname, err = dns.ParseDomain(submitconf.Host)
xsavecheckf(err, "parsing remote hostname")
}
// todo: implement SRV and DANE, allowing for a simpler config file (just the email address & password)
client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, ourHostname, remoteHostname, auth, nil, nil, nil)
xsavecheckf(err, "open smtp session")
err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false)
xsavecheckf(err, "submit message")
if err := client.Close(); err != nil {
log.Printf("closing smtp session after message was sent: %v", err)
}
}