Files
selfpost/test/e2e/mail_helpers.go
T
mixandClaude Opus 5.5 30fcf978e0
test / test (push) Waiting to run
docs: clear shipped plans and closed work out of the documents
Owner, 2026-10-18: "Надо почистить документы от устаревшего и сделанного."

- Deleted, shipped: plans/delivery-log-storage, from-header-binding,
  inbound-antispam-panel, inbound-upstream-result, panel-dkim-signing,
  panel-notifications, and the own-mta-builds analysis (its three rows
  shipped as 2.6.1, 2.7.0, 2.8.0). What outlived them moved first: when a
  filter-list counter is worth a migration, and the notifications' "when"
  table, into architecture.md; the sender check's own-process trigger into
  security.md. Code comments that cited them point at architecture.md or
  security.md; CHANGELOG keeps their names, unlinked.
- Kept, marked closed: audit-2026-10-12, assumptions-2026-10-14 and
  security-audit-2026-10-17 - the code cites their finding ids in 50-84
  files. Their file:line refs are of the tree each audited.
- roadmap.md: open work only - stage H of the security audit (the owner's),
  go-milter-upstream (the owner posts), and queue-table, which the
  redesign left undecided and only its own shipped section still named;
  Dropped and Ideas as they were. 2.x versioning instead of 1.x.
- development.md: the 47-row planned-version-cuts table (history, in the
  CHANGELOG) gone; a stage's last step now removes its roadmap item and
  plan. Docs map lists the closed audits.
- panel-redesign.md: the done checklist out; Room for roadmap items says
  which drawn screens are unbuilt and why.
- Stale facts fixed: schema-migrations.md and architecture.md at schema 14
  (0013's users columns and user_links, 0014's secure TLS mode);
  security.md's accepted risk "profile saved without the password" - the
  e-mail asks for it since 2.14.0.

Tests: go build, go vet; go test of cmd and the touched internal packages
pass except logtail's three Windows-bound rotation tests (rename of an open
file; CI is Linux). Only comments changed in Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:37:22 +03:00

200 lines
7.3 KiB
Go

package e2e
import (
"bytes"
"fmt"
"net/mail"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/emersion/go-msgauth/dkim"
)
// senderDomain and recipient are fixed for the whole run: one sending domain
// registered in the panel, and the sink-MX as the single recipient every
// positive send targets. Here, not in main_test.go: smtp_client.go uses them,
// and the package builds without its tests (staticcheck reads it so).
const (
senderDomain = "sender.e2e.test"
recipient = "rcpt@sink.e2e.test"
)
// findSinkMessage polls the sink-MX's dump directory (see test/e2e/sink) for
// a file whose contents contain token — the unique Subject-line marker every
// test send carries — and returns it. Polling a directory listing rather than
// sleeping a fixed duration is what keeps this deterministic even under a
// slow CI runner (plan C.4).
func findSinkMessage(stageDir, token string, timeout time.Duration) ([]byte, error) {
_, raw, err := findSinkFile(stageDir, token, timeout)
return raw, err
}
// findSinkFile is findSinkMessage that also says the file's name in the
// sink's /mail (the same directory, mounted).
func findSinkFile(stageDir, token string, timeout time.Duration) (string, []byte, error) {
dir := filepath.Join(stageDir, "mail-stage")
var name string
var found []byte
err := waitFor(fmt.Sprintf("sink-MX to receive a message tagged %q", token), timeout, 300*time.Millisecond, func() (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
if bytes.Contains(b, []byte(token)) {
name, found = e.Name(), b
return true, nil
}
}
return false, fmt.Errorf("no dumped message contains %q yet (%d on disk)", token, len(entries))
})
return name, found, err
}
// verifyDKIM checks that raw (a message dumped by the sink) carries a DKIM
// signature for domain and that every signature for it validates against the
// public key published in the fake zone — i.e. exactly what a real receiver
// would check, using DNS the harness itself controls instead of
// net.DefaultResolver (plan C.4).
func verifyDKIM(raw []byte, domain string) error {
verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
LookupTXT: lookupTXTFunc(),
})
if err != nil {
return fmt.Errorf("dkim verify: %w", err)
}
n := 0
for _, v := range verifications {
if v.Domain == domain {
if v.Err != nil {
return fmt.Errorf("dkim signature for %s did not validate: %w", domain, v.Err)
}
n++
}
}
if n == 0 {
return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications))
}
return nil
}
// checkPanelSignature is the oracle of the panel's DKIM signer
// (docs/architecture.md § DKIM signing): the message carries one DKIM
// signature, the panel's, for domain - signed as
// OpenDKIM signed until 2.8.0 (relaxed/simple, rsa-sha256, From over-signed)
// - and dkimpy (python3-dkim in the sink image), an implementation that is
// not go-msgauth, the panel's library, verifies it. Stage 1 compared it with
// OpenDKIM's on every message before OpenDKIM left. name is the file in the
// sink's /mail.
func checkPanelSignature(s *stack, name string, raw []byte, domain string) error {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parse the delivered message: %w", err)
}
sigs := msg.Header["Dkim-Signature"]
if len(sigs) != 1 {
return fmt.Errorf("%d DKIM signatures, want 1, the panel's", len(sigs))
}
tags := dkimTags(sigs[0])
for tag, want := range map[string]string{"d": domain, "c": "relaxed/simple", "a": "rsa-sha256"} {
if tags[tag] != want {
return fmt.Errorf("the signature has %s=%q, want %q", tag, tags[tag], want)
}
}
if h := strings.ToLower(tags["h"]); !strings.HasPrefix(h, "from:") || !strings.HasSuffix(h, ":from") {
return fmt.Errorf("the signature's h=%q does not sign From and over-sign it", tags["h"])
}
out, err := s.execIn("sink", "sh", "-c", fmt.Sprintf("dkimverify --index 0 < '/mail/%s'", name))
if err != nil || !strings.Contains(out, "signature ok") {
return fmt.Errorf("dkimpy: %v\n%s", err, out)
}
return nil
}
// dkimTags splits a DKIM-Signature value into its tags, white space removed
// (RFC 6376 § 3.2: tag=value pairs separated by ";", FWS anywhere).
func dkimTags(v string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(v, ";") {
k, val, ok := strings.Cut(part, "=")
if !ok {
continue
}
tags[strings.TrimSpace(k)] = strings.Join(strings.Fields(val), "")
}
return tags
}
// postfixReceived returns the Received: headers of raw that a Postfix smtpd
// wrote - "(Postfix)" after the host name, the only form it writes (Postfix
// 3.10.13, src/smtpd/smtpd.c:3627-3645, "by %s (%s)" with var_mail_name).
// In the stand only SelfPost's Postfix writes one: the tests compose their
// messages without any, and the sink, smtp-sink, adds its own with "by
// <host> (smtp-sink)" (src/smtpstone/smtp-sink.c:520-531,
// mail_file_finish_header()), which the "(Postfix)" filter leaves out.
func postfixReceived(raw []byte) ([]string, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse the delivered message: %w", err)
}
var out []string
for _, v := range msg.Header["Received"] {
if strings.Contains(v, "(Postfix)") {
out = append(out, v)
}
}
return out, nil
}
// checkReceivedHidden: the submission ports set smtpd_hide_client_session
// (build/postfix-config.sh), so SelfPost's Received: header is the redacted
// one - it opens with "by", names no client and no protocol (Postfix 3.10.13,
// src/smtpd/smtpd.c:3495, the "from" part written only when the parameter
// is off, and with_protocol emptied with it).
func checkReceivedHidden(raw []byte) error {
recv, err := postfixReceived(raw)
if err != nil {
return err
}
if len(recv) != 1 {
return fmt.Errorf("want one Received: header from SelfPost's Postfix, got %d: %q", len(recv), recv)
}
if !strings.HasPrefix(recv[0], "by ") || strings.Contains(recv[0], " with ") {
return fmt.Errorf("the Received: header names the client session: %q", recv[0])
}
return nil
}
// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log
// line, which is how the harness learns the source address Postfix itself
// observed for a just-made connection (needed to configure a level-2 rate
// limit's allowed-IPs list — the address a host-published port is seen as
// inside the container depends on Docker's NAT and isn't worth hard-coding).
// Postfix 3.10.13, src/smtpd/smtpd.c, smtpd_service(): msg_info("connect from
// %s", namaddr), namaddr being "name[addr]".
var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`)
// lastSMTPClientIP reads mail.log inside the selfpost container and returns
// the most recent address Postfix's smtpd logged a connection from.
func lastSMTPClientIP(s *stack) (string, error) {
out, err := s.execIn("selfpost", "tail", "-n", "200", "/data/log/mail.log")
if err != nil {
return "", err
}
matches := connectFromPattern.FindAllStringSubmatch(out, -1)
if len(matches) == 0 {
return "", fmt.Errorf("no \"connect from\" line in mail.log yet")
}
return matches[len(matches)-1][1], nil
}