Files
selfpost/internal/notify/mailer.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00

105 lines
3.8 KiB
Go

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@<hostname>. 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.
// The panel's signer signs them 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, "@") {
// (validate.AccountEmail refuses the same, so a user is told at the
// form and not here, once a minute, in the log.)
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)
// sendmail hands the message to postdrop; if that child outlives the
// timeout holding the output pipe, do not wait for it for ever.
cmd.WaitDelay = 5 * time.Second
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
}