test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
7.5 KiB
Go
222 lines
7.5 KiB
Go
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// settingNotifications is the instance switch for notification e-mails. A
|
||
// missing value means on: the switch exists to turn them off.
|
||
const settingNotifications = "notifications"
|
||
|
||
// NotificationsEnabled reports whether the instance sends notification e-mails
|
||
// at all (Server › Settings).
|
||
func (s *Store) NotificationsEnabled() (bool, error) {
|
||
v, err := s.GetSetting(settingNotifications)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return v != "off", nil
|
||
}
|
||
|
||
// SetNotificationsEnabled turns notification e-mails on or off for everyone.
|
||
func (s *Store) SetNotificationsEnabled(on bool) error {
|
||
v := "on"
|
||
if !on {
|
||
v = "off"
|
||
}
|
||
return s.SetSetting(settingNotifications, v)
|
||
}
|
||
|
||
// SetUserNotify sets a user's own switch (Account).
|
||
func (s *Store) SetUserNotify(id int64, on bool) error {
|
||
res, err := s.db.Exec("UPDATE users SET notify = ? WHERE id = ?", on, id)
|
||
if err != nil {
|
||
return fmt.Errorf("set user notify: %w", err)
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrUserNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// NotifyRecipients returns the addresses a notification goes to: every global
|
||
// user, and for a subject that is a sending domain's (domainName not empty)
|
||
// also the domain users it is assigned to - by name on their list, or by
|
||
// "all domains". Only users with an e-mail and their own switch on; an address
|
||
// two users share is returned once.
|
||
func (s *Store) NotifyRecipients(domainName string) ([]string, error) {
|
||
query := `SELECT DISTINCT email FROM users
|
||
WHERE notify = 1 AND email <> '' AND role = ?`
|
||
args := []any{RoleGlobal}
|
||
if domainName != "" {
|
||
query = `SELECT DISTINCT email FROM users
|
||
WHERE notify = 1 AND email <> '' AND (
|
||
role = ?
|
||
OR all_domains = 1
|
||
OR EXISTS (SELECT 1 FROM user_domains ud JOIN domains d ON d.id = ud.domain_id
|
||
WHERE ud.user_id = users.id AND d.name = ?))`
|
||
args = append(args, domainName)
|
||
}
|
||
rows, err := s.db.Query(query+" ORDER BY email", args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("notify recipients: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []string
|
||
for rows.Next() {
|
||
var email string
|
||
if err := rows.Scan(&email); err != nil {
|
||
return nil, fmt.Errorf("notify recipients scan: %w", err)
|
||
}
|
||
out = append(out, email)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// NotifyState is what the panel remembers about one subject of notification
|
||
// (migration 0004 describes the keys and the fields).
|
||
type NotifyState struct {
|
||
Key string
|
||
Phase string
|
||
Count int
|
||
Mark string
|
||
Since time.Time
|
||
SentAt time.Time // zero when nothing was sent yet
|
||
}
|
||
|
||
// GetNotifyState returns the state kept under key; ok is false when there is
|
||
// none, which is the state of a subject nothing is wrong with.
|
||
func (s *Store) GetNotifyState(key string) (NotifyState, bool, error) {
|
||
st := NotifyState{Key: key}
|
||
var since, sentAt string
|
||
err := s.db.QueryRow(
|
||
"SELECT phase, count, mark, since, sent_at FROM notify_state WHERE key = ?", key,
|
||
).Scan(&st.Phase, &st.Count, &st.Mark, &since, &sentAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return NotifyState{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return NotifyState{}, false, fmt.Errorf("get notify state %s: %w", key, err)
|
||
}
|
||
st.Since, _ = time.Parse(time.RFC3339, since)
|
||
if sentAt != "" {
|
||
st.SentAt, _ = time.Parse(time.RFC3339, sentAt)
|
||
}
|
||
return st, true, nil
|
||
}
|
||
|
||
// SetNotifyState writes the state of one subject, replacing what was there.
|
||
func (s *Store) SetNotifyState(st NotifyState) error {
|
||
sentAt := ""
|
||
if !st.SentAt.IsZero() {
|
||
sentAt = st.SentAt.UTC().Format(time.RFC3339)
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO notify_state (key, phase, count, mark, since, sent_at) VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(key) DO UPDATE SET phase = excluded.phase, count = excluded.count,
|
||
mark = excluded.mark, since = excluded.since, sent_at = excluded.sent_at`,
|
||
st.Key, st.Phase, st.Count, st.Mark, st.Since.UTC().Format(time.RFC3339), sentAt,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("set notify state %s: %w", st.Key, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteNotifyState forgets a subject: nothing is wrong with it any more, or
|
||
// it no longer exists.
|
||
func (s *Store) DeleteNotifyState(key string) error {
|
||
if _, err := s.db.Exec("DELETE FROM notify_state WHERE key = ?", key); err != nil {
|
||
return fmt.Errorf("delete notify state %s: %w", key, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
|
||
// states of subjects that are gone - a deleted domain - can be found.
|
||
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
|
||
rows, err := s.db.Query(
|
||
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list notify state: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []string
|
||
for rows.Next() {
|
||
var key string
|
||
if err := rows.Scan(&key); err != nil {
|
||
return nil, fmt.Errorf("list notify state scan: %w", err)
|
||
}
|
||
out = append(out, key)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// LimitRefusal is the mail the applications of one sending domain were refused
|
||
// under a level-2 rate limit in a span of time.
|
||
type LimitRefusal struct {
|
||
Domain string
|
||
// Apps are the logins that were refused, in order.
|
||
Apps []string
|
||
Count int
|
||
First time.Time
|
||
}
|
||
|
||
// LimitRefusalsSince returns, per sending domain, the messages the journal
|
||
// milter refused under a level-2 limit at or after since.
|
||
//
|
||
// A refusal is a send-log row with status "rejected", and what such a row
|
||
// says about itself is not to be believed whole. Its domain is the one in
|
||
// MAIL FROM, which the client typed and Postfix has not yet held against the
|
||
// login when the milter refuses; so the domain here is the one the login is
|
||
// registered under, and a row whose login is no application's is not counted.
|
||
// The same status is written when an application is refused for its client
|
||
// address (applications.auth_ip_restrict), which is no limit at all; so a
|
||
// login counts only while a level-2 limit is set on it or on its domain.
|
||
// Since 2.4.0 a row says why it was refused, and one refused for its client
|
||
// address or its From: line is left out by that; a row from before has no
|
||
// reason and is still judged the old way.
|
||
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
||
// The status is a literal so that the partial index of migration 0005
|
||
// answers the question.
|
||
rows, err := s.db.Query(
|
||
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
|
||
FROM send_log l
|
||
JOIN applications a ON a.login = l.app_login
|
||
JOIN domains d ON d.id = a.domain_id
|
||
WHERE l.status = 'rejected' AND l.created_at >= ? AND l.reason IN ('', 'limit')
|
||
AND (EXISTS (SELECT 1 FROM rate_limits r WHERE r.scope = 'application' AND r.ref_id = a.id)
|
||
OR EXISTS (SELECT 1 FROM rate_limits r WHERE r.scope = 'domain' AND r.ref_id = d.id))
|
||
GROUP BY d.name, a.login ORDER BY d.name, a.login`,
|
||
since.UTC().Format(time.RFC3339),
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("limit refusals: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []LimitRefusal
|
||
for rows.Next() {
|
||
var (
|
||
domain, login, first string
|
||
count int
|
||
)
|
||
if err := rows.Scan(&domain, &login, &count, &first); err != nil {
|
||
return nil, fmt.Errorf("limit refusals scan: %w", err)
|
||
}
|
||
at, _ := time.Parse(time.RFC3339, first)
|
||
if n := len(out); n > 0 && out[n-1].Domain == domain {
|
||
out[n-1].Apps = append(out[n-1].Apps, login)
|
||
out[n-1].Count += count
|
||
if at.Before(out[n-1].First) {
|
||
out[n-1].First = at
|
||
}
|
||
continue
|
||
}
|
||
out = append(out, LimitRefusal{Domain: domain, Apps: []string{login}, Count: count, First: at})
|
||
}
|
||
return out, rows.Err()
|
||
}
|