package notify import ( "bytes" "context" "crypto/rand" "encoding/hex" "fmt" "mime" "os/exec" "strings" "time" ) const ( sendmailPath = "/usr/sbin/sendmail" sendmailTimeout = 20 * time.Second ) // Mailer hands notifications to Postfix by local submission, as // postmaster@. They are the panel's own mail: they pass no SASL // login, are not rows of the send log and count against no rate limit. // Postfix signs them through OpenDKIM with the key of the server's own name // (non_smtpd_milters in build/postfix-config.sh, domain.Service.SetServerName). type Mailer struct { hostname string // run submits a finished message; a field so tests can read it instead. run func(from, to string, msg []byte) error } // NewMailer returns a mailer for the server named hostname. func NewMailer(hostname string) *Mailer { return &Mailer{hostname: hostname, run: sendmail} } // From is the address notifications are sent from. func From(hostname string) string { return "postmaster@" + hostname } // Send submits one message to one recipient. func (m *Mailer) Send(to, subject, body string) error { // The address is a panel user's, validated when it was saved. It goes into // a header and onto a command line all the same, so what cannot be part of // an address is refused here as well (security.md). if to == "" || strings.ContainsAny(to, " \t\r\n<>,;\"") || strings.HasPrefix(to, "-") || !strings.Contains(to, "@") { return fmt.Errorf("not an address a notification can go to: %q", to) } from := From(m.hostname) id, err := messageID(m.hostname) if err != nil { return err } var b bytes.Buffer fmt.Fprintf(&b, "From: SelfPost <%s>\r\n", from) fmt.Fprintf(&b, "To: <%s>\r\n", to) fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", oneLine(subject))) fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) fmt.Fprintf(&b, "Message-ID: %s\r\n", id) // Tells a vacation responder and a ticket system that nobody wrote this. b.WriteString("Auto-Submitted: auto-generated\r\n") b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") b.WriteString("Content-Transfer-Encoding: 8bit\r\n") b.WriteString("\r\n") b.WriteString(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")) return m.run(from, to, b.Bytes()) } // oneLine keeps a subject on its line whatever went into it. func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } func messageID(hostname string) (string, error) { var raw [12]byte if _, err := rand.Read(raw[:]); err != nil { return "", fmt.Errorf("message id: %w", err) } return fmt.Sprintf("<%s.%d@%s>", hex.EncodeToString(raw[:]), time.Now().Unix(), hostname), nil } // sendmail submits msg through Postfix's sendmail command. The argument list is // fixed apart from the two addresses, which follow "--" so that neither can be // read as an option; nothing goes through a shell (security.md). -i keeps a // line holding a single dot from ending the message. func sendmail(from, to string, msg []byte) error { ctx, cancel := context.WithTimeout(context.Background(), sendmailTimeout) defer cancel() cmd := exec.CommandContext(ctx, sendmailPath, "-i", "-f", from, "--", to) cmd.Stdin = bytes.NewReader(msg) out, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("sendmail timed out after %s", sendmailTimeout) } if err != nil { return fmt.Errorf("sendmail: %w: %s", err, strings.TrimSpace(string(out))) } return nil }