Files
selfpost/internal/notify/mailer.go
T
mixandClaude Opus 5.5 f2ff8b4fea test-email: a test message from Server › Health
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.

Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.

Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.

Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:42:21 +03:00

121 lines
4.4 KiB
Go

package notify
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"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 {
id, err := NewMessageID(m.hostname)
if err != nil {
return err
}
return m.SendWithID(to, subject, body, id)
}
// NewMessageID makes a Message-ID of the server's own name, for a message the
// caller wants to know again in mail.log (the panel's test message).
func NewMessageID(hostname string) (string, error) { return messageID(hostname) }
// SendWithID is Send with the Message-ID given: cleanup logs it with the
// message's queue id, which is how the panel finds its test message in
// mail.log (logtail.AwaitMessageID).
func (m *Mailer) SendWithID(to, subject, body, id 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)
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
}
// ErrTimeout is sendmail not answering in time: postdrop may have queued the
// message all the same.
var ErrTimeout = errors.New("sendmail timed out")
// 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("%w after %s", ErrTimeout, sendmailTimeout)
}
if err != nil {
return fmt.Errorf("sendmail: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}